AGENTS.md
Birdbrain
Open source web investigation & capture tool. Electron desktop app with a companion Chrome extension for capturing and analyzing web content.
Commands
pnpm dev- Start Electron app in dev mode (electron-vite)pnpm build- Build the Electron apppnpm build:extension- Build the Chrome extensionpnpm dev:extension- Build Chrome extension in watch mode (background and popup bundles only; does not watch/rebuild the content script IIFE build)pnpm build:verifier- Build the standalone verifier binary (scripts/build-verifier.mjs)pnpm test- Run tests (vitest, via Electron runtime)pnpm test:watch- Run tests in watch modepnpm test:coverage/pnpm coverage:report/pnpm coverage:all- Coverage run and reportspnpm lint- ESLint (.ts, .tsx)pnpm typecheck- Typecheck all six tsconfig projects:srcmain/preload/shared,srcrenderer, extension, thentests/(node flavour and web flavour) ande2e/pnpm format- Prettier format src/ and extension/pnpm rebuild:electron- Rebuild native deps (better-sqlite3)pnpm test:e2e- Run E2E tests (Playwright + Electron, runspnpm buildfirst)pnpm test:e2e:debug- Run E2E tests with Playwright inspectorpnpm package/pnpm package:win/pnpm package:mac/pnpm package:linux- Package for distribution
Docs site commands run from website/ (separate lockfile — see "Documentation site"): pnpm dev, pnpm build, pnpm types:check.
Run everything on Node 20. .nvmrc and .mise.toml pin it, and CI reads .nvmrc (node-version-file). engines.node is only a floor (>=20.19.0) — Node 24 satisfies it, so engines will not keep you off the broken version. .mise.toml exists because mise ignores .nvmrc by default, so shells and agent worktrees would otherwise land on whatever Node is newest. Under Node 24 Electron's postinstall silently fails to extract the binary (extract-zip's promise never settles): install exits 0 but leaves node_modules/electron/dist broken, which is what scripts/ensure-electron.mjs now backstops. If Electron is mysteriously missing, check node --version first.
Architecture
Electron + React 19 + TanStack Router + React Query + Chrome Extension + SQLite (better-sqlite3).
Process model
- Main process (
src/main/) - Electron main, SQLite database, Hono capture server, settings, export - Preload (
src/preload/) - IPC bridge exposing typed channels to renderer viawindow.birdbrain - Renderer (
src/renderer/) - React 19 + Tailwind v4 + Zustand + TanStack Router + React Query - Chrome extension (
extension/) - Content script + background service worker + popup that sends captures to the Hono server
Key directories
| 1 | src/main/services/ # Main-process services: captureServer, captureStore, storage, export, pdfExport, |
| 2 | # settings, hash, manifest, captureLifecycle, selectorLifecycle, recapture, |
| 3 | # annotations, caseArchive, zip/zipRead, timestamp/trustedTime/tsaTrust, |
| 4 | # signingKey, certification, waybackMachine, diagnostics, updater, deepLink, |
| 5 | # noteAnchorResolver, logSafe |
| 6 | src/main/services/db/ # Data access layer — see "Database" below (core, migrations, per-domain repos, dbAdmin) |
| 7 | src/main/services/ai/ # OpenRouter chat client + capture analysis service |
| 8 | src/main/services/extraction/ # Extracted-data pipeline (source, IOC adapter, sanitizer, validators) |
| 9 | src/main/ipcHandlers.ts # All IPC handler registrations |
| 10 | src/main/ipcWrap.ts # handle() wrapper + IpcFailure error envelope |
| 11 | src/shared/types.ts # Cross-process domain types (Case, Capture, Tag, Selector, Note, Settings, etc.) |
| 12 | src/shared/ipc.ts # IPC channel definitions and payload types |
| 13 | src/shared/constants.ts # Constants (CAPTURE_SERVER_PORT, MAX_MHTML_SIZE, MANIFEST_FILENAME, etc.) |
| 14 | src/shared/schemas.ts # Zod schemas validating settings and imported/exported documents |
| 15 | src/shared/noteDoc.ts # Rich-text note document model + body derivation |
| 16 | src/shared/noteAnchor.ts # Note anchor model + text-anchor resolution |
| 17 | src/shared/verify/ # Evidence-package verification (canonicalJson, manifestChain, signature, timestampToken) |
| 18 | src/verifier/cli.ts # Standalone verifier CLI entry point |
| 19 | src/renderer/routes/ # TanStack Router route definitions (root tree plus captures route module) |
| 20 | src/renderer/stores/ # Zustand store (appStore.ts) |
| 21 | src/renderer/hooks/ # React hooks for theme, search, filters, viewport, favorites, session restore, server status, etc. |
| 22 | src/renderer/lib/ # React Query client, query/mutation factories, motion presets, formatting helpers |
| 23 | src/renderer/components/ # UI organized by feature — see "UI components" below |
| 24 | extension/src/ # Chrome extension source (background, content, popup, toast, utils/api, utils/headers) |
| 25 | tests/ # Vitest unit tests |
| 26 | e2e/ # Playwright E2E tests |
| 27 | docs/ # Local working notes — see docs/README.md for layout (reference/, specs/, plans/, archive/) |
Path aliases
@main/*→src/main/*(main + preload)@shared/*→src/shared/*(all processes)@renderer/*→src/renderer/*(renderer only)
IPC pattern
All renderer↔main communication uses typed IPC channels defined in src/shared/ipc.ts. Channels follow domain:action naming (e.g., cases:create, selectors:create). Event channels (main→renderer) use event: prefix.
Domains (read IPC_CHANNELS in src/shared/ipc.ts for the authoritative list): cases, captures, recapture, tags, search, settings, export, selectors, notes, wayback, extractedData, annotations, extension, shell, app, diagnostics, updates, ai, db — plus the event: main→renderer channels.
The preload script exposes these via window.birdbrain with typed invoke/on methods.
Routing
TanStack Router (@tanstack/react-router) with the following route tree:
| 1 | / → Dashboard |
| 2 | /settings → SettingsView |
| 3 | /extension-setup → InstallExtensionGuide |
| 4 | /cases/new → NewCaseWizard |
| 5 | /cases/$caseId → CaseWorkspace (layout with tabs) |
| 6 | ├── / → redirects to /overview |
| 7 | ├── /overview → CaseOverview |
| 8 | ├── /captures → CaptureList + CaptureViewer (split view) |
| 9 | ├── /selectors → SelectorsOverview |
| 10 | ├── /notes → NotesOverview |
| 11 | ├── /tags → TagsOverview |
| 12 | └── /data → DataExplorer |
Root layout in __root.tsx renders TopBar, optional case Sidebar, main content area, CommandPalette, onboarding overlay, and devtools in development.
Data fetching
React Query (@tanstack/react-query) manages all server state. Configuration in src/renderer/lib/:
queryClient.ts- retry=false, staleTime=30s, refetchOnWindowFocus=falsequeries.ts- Query key factory, typed query options, and domain-specific mutation hooks (useCasesMutations, useCapturesMutations, etc.) with automatic cache invalidation
State management
Zustand store (src/renderer/stores/appStore.ts) for UI-only state:
- Session state (sessionActive, connectedToExtension)
- Selection state (selectedCaptureId, selectedCaptureIds)
- Search and filter state (searchQuery, activeSelectorFilters, filteredCaptureIds)
- Capture activity (captureEvents, captureStats)
Database
SQLite via better-sqlite3. The data-access layer lives in src/main/services/db/:
core.ts- Owns the connection.initDatabase()opens the file, sets the pragmas (journal_mode = WAL,foreign_keys = ON,busy_timeout), then runs migrations. Also exportsgetDb(),closeDatabase(),withTransaction(), theImportCtxarchive-import context, andID_PROBE_TABLES/hasRowWithId()for archive-import id collision remapping.migrations.ts- The whole schema history in onerunMigrations(db)function: a sequence ofif (version < N)blocks, each running its DDL inside a transaction that ends by settingdb.pragma('user_version = N'). New schema changes append a new block and bumpLATEST_SCHEMA_VERSIONincore.ts— that constant is the single source of truth for the current version, so read it rather than counting blocks.- Per-domain repos -
caseRepo.ts,captureRepo.ts,tagRepo.ts,selectorRepo.ts,noteRepo.ts,extractedDataRepo.ts,waybackRefRepo.ts. Each owns the SQL for its aggregate. dbAdmin.ts- Generic table browse/edit, vacuum, FTS rebuild, orphan cleanup, backup/restore, CSV export (backs Settings → Database).diagnosticsRepo.ts- Read-only DB facts for Settings → Diagnostics, including the liveuser_versionalongsideLATEST_SCHEMA_VERSION.
Raw connection access is lint-enforced: eslint.config.js restricts importing getDb from @main/services/db/core anywhere under src/main/ outside src/main/services/db/. New SQL belongs in a repo module; the few legacy exceptions carry an eslint-disable with a reason.
Tables and indexes: read migrations.ts — it is the only place tables and indexes are declared, in chronological order. Broadly: case/capture core plus tags, selectors and selector matches, notes, favorites, capture analyses, extracted data, annotations and annotation pins, pinned Wayback refs (table capture_archive_refs), capture texts, and FTS5 virtual tables shadowing captures, notes, and extracted data. Never assume a table exists because it appears in an early migration — later migrations drop and rebuild some (e.g. the v1 entities and case_analyses tables were dropped, and captures_fts was later dropped and recreated).
Theme system
Light/dark theme support using CSS custom properties and Tailwind v4:
- CSS tokens (
src/renderer/styles/globals.css) -@themeblock defines semantic color variables (canvas, text, accent, border, surface, etc.) with light/dark variants via.darkclass - useTheme hook (
src/renderer/hooks/useTheme.ts) - Manages theme state, localStorage persistence,darkclass on<html>, 400ms transition animations, and IPC sync to settings - Flash prevention - Inline script in HTML prevents theme flicker on load
- Component convention - Prefer semantic token classes (e.g.,
bg-canvas,text-text-primary,border-border) over raw Tailwind colors; exceptions include overlays (bg-black) and status/severity colors (bg-red-600, etc.)
Capture server
Hono HTTP server (src/main/services/captureServer.ts) on port 19845 receives captures from the Chrome extension. Supports both HTML and MHTML forensic capture formats. Captures are stored as files on disk organized by case directory with SHA-256 hash verification and hash-chained audit manifests.
Chrome extension
Located in extension/src/:
- background.ts - Service worker managing extension state and tab capture events
- content.ts - Injected into pages for HTML/screenshot capture and selector detection
- popup/ - React-based popup UI with case selector and capture controls
- utils/api.ts - HTTP client targeting
http://127.0.0.1:19845
Built separately via pnpm build:extension (uses extension/vite.config.ts).
AI services
Two OpenRouter modules, split by role:
src/main/services/openrouter.ts- Credential/catalog surface:testApiKey()andlistModels(), exposed over thesettings:testOpenRouterandsettings:listModelschannels and driven by Settings → AI.src/main/services/ai/openrouter.ts- The chat client:sendPrompt()(with retry/backoff) andtruncateForContext().
src/main/services/ai/analysisService.ts runs per-capture analysis on top of that client (analyzeCapture, saveAnalysis, getAnalysis, plus archive import/export helpers) and persists to the capture_analyses table. Handlers are registered for ai:analyze, ai:saveAnalysis, and ai:getAnalysis; the renderer surface is captures/AnalysisTab.tsx.
Note: the v1 entities and case_analyses tables were dropped in an early migration, but per-capture analysis exists again via capture_analyses — do not assume AI analysis is dead code.
Settings persist openRouterApiKey, defaultModel, and analysisSystemPrompt (defaulting to DEFAULT_ANALYSIS_SYSTEM_PROMPT in src/shared/constants.ts).
UI components
Organized by feature under src/renderer/components/:
- captures/ - Capture list/viewer workflow, details panel/rail, add-URL box, provenance, wayback/analysis/forensics tabs, MHTML viewer, download menu, inline tag/note editing hooks, verify mutation, and the annotation editor under
captures/annotation/(canvas, zoom/pan and editor hooks, pin popover, shape components underannotation/shapes/) - dashboard/ - Dashboard, CaseCard, DashboardFooter, ExtensionBanner, HeroSection, QuickStartGuide, RecentCases, plus case workspace components under
dashboard/cases/(CaseWorkspace, CreateCaseDialog, DataExplorer, ImportCaseDialog, NewCaseWizard) - export/ - ExportDialog, ExportMenu, ExportProgress, ExportComplete
- extension/ - InstallExtensionGuide, InstallExtensionStepper, installSteps.tsx
- layout/ - TopBar, Sidebar, CommandPalette, OnboardingWizard
- notes/ - AddNoteModal, CreateNoteCard, NoteCard, NoteBody, NoteEditor, NotesOverview, useNoteEditor.ts
- overview/ - CaseOverview, CaseSubhead, ActivityTimeline, MetricRow, RecentCapturesStrip, SelectorCoverageBlock, SinceLastVisitBanner, SourcesBlock, VerifyBar, overviewModel.ts
- search/ - SearchBar
- selectors/ - BulkAddSelectorsModal, CreateSelectorCard, CreateSelectorPopover, SelectorFilterFooter, SelectorTable, SelectorTableRow, SelectorsOverview, selectorUtils.ts
- settings/ - SettingsView, AIConfig, AppearanceConfig, CapturePreferences, DatabaseAdmin, DiagnosticsPanel, OperatorConfig, StorageConfig, UpdatesConfig, About, plus database utility views under
settings/db/ - status/ - CaptureHealth, ConnectionStatus, SessionControls
- tags/ - TagBadge, TagManager, TagsOverview
- ui/ - Shared primitives re-exported from
ui/index.ts: badge, button, card, dialog, input, label, scroll-area, skeleton, tabs, textarea
Documentation conventions
All design docs, specs, and implementation plans live under docs/ per the layout in docs/README.md. Canonical paths:
- Specs / design briefs / spikes →
docs/specs/YYYY-MM-DD-<slug>-design.md(or-spike.md,-brief.md,-assessment.md) — tracked - Implementation plans / checklists →
docs/plans/YYYY-MM-DD-<slug>.md— tracked - Long-lived reference →
website/content/docs/<topic>.mdx(no date prefix) — tracked, and published to the docs site - Architecture decisions →
docs/adr/NNNN-<slug>.md— tracked - Superseded →
docs/archive/(preserve original filename) — tracked
Long-lived reference docs moved out of docs/reference/ into website/content/docs/ when the docs site was set up — they are the site's content now. Adding one means adding an .mdx file with title/description frontmatter plus an entry in website/content/docs/meta.json (pages absent from meta.json are silently dropped from the sidebar). See "Documentation site" below for the MDX constraints.
docs/plans/ is tracked (since July 2026). Plans are still author-time working notes: they get checked off and go stale, and staleness is expected.
Docs may ship in the same PR as the code they describe. There is no requirement to split specs, plans, ADRs, or reference docs onto their own PR or their own commit. Bundling a doc with the src/** change it documents is normal and preferred — a guide for a feature that has not merged yet is worth less on its own, and the split costs more than it returns.
Override for agentic tooling: When a skill or agent specifies a different default path (e.g. Superpowers' docs/superpowers/specs/ and docs/superpowers/plans/), treat the canonical paths above as the user-preference override. Write specs to docs/specs/ and plans to docs/plans/. The legacy docs/superpowers/ tree is frozen — do not add new files there.
Documentation site
website/ is the public docs site — Next.js 16 + Fumadocs UI/MDX, statically exported and published to GitHub Pages at https://thebristolsound.github.io/birdbrain/ by .github/workflows/docs.yml.
It is a deliberately isolated sub-project. It has its own package.json, pnpm-lock.yaml, and node_modules. The repo root does have a pnpm-workspace.yaml, but only to hold the pnpm settings that used to live in the pnpm field of package.json (onlyBuiltDependencies, overrides, supportedArchitectures) — pnpm 10.28 stopped reading them there. It deliberately has no packages: key, so nothing is registered as a workspace member and website/ stays isolated. Do not add one.
website/pnpm-workspace.yaml enforces that isolation from the other side: it makes website/ its own workspace root, so a pnpm command run inside website/ stops there instead of walking up and inheriting the root's overrides and onlyBuiltDependencies. It also carries the site's own allowBuilds approvals (esbuild, sharp). Consequences:
- Run its commands from inside
website/:pnpm install,pnpm dev,pnpm build,pnpm types:check. A rootpnpm installdoes not touch it. - The root toolchain ignores it:
eslint.config.jslistswebsite/,pnpm formatis scoped tosrc/+extension/, the root tsconfigs only includesrc/**, andbuild.filesin the rootpackage.jsonexcludeswebsite/**/*so it never ships inside the packaged app. next.config.mjspinsturbopack.roottowebsite/, or Turbopack finds the root lockfile and infers the wrong workspace root.
Content lives in website/content/docs/ (.mdx + meta.json), images in website/public/assets/. Things worth knowing before editing content:
- Bare
{...}in prose breaks the build. MDX parses braces as JSX expressions, so{source}or{a, b}in body text is a compile error. Wrap them in backticks. - Internal doc links need the
./name.mdxform.createRelativeLinkonly rewrites hrefs starting with./or../; a bare slug is emitted as-is and resolves wrong undertrailingSlash: true. - Image paths are
public/-relative (/assets/x.png). Fumadocs turns them intonext/imageimports, sobasePathis applied for you — do not hardcode/birdbrain/. - Anything that builds a URL by hand does need the prefix; import
basePathfromwebsite/lib/base-path.mjs(that is why the static search client passesfrom).
Mintlify mirror (evaluation)
A Mintlify deployment (birdbrain) renders the same MDX as a second, read-only mirror while
the platform is being evaluated. GitHub Pages remains the published site — Mintlify is not
wired into CI and nothing in the root toolchain depends on it.
website/content/docs.json is its config. Note the placement: it is a sibling of
content/docs/, not inside it. defineDocs({ dir: 'content/docs' }) globs JSON files under
that directory into the Fumadocs meta collection, so a docs.json placed in content/docs/
risks being parsed as a meta node and breaking pnpm build. Keep it one level up.
The deployment's git source must be configured by hand in the Mintlify dashboard — the repo cannot set it. Two fields matter:
- Deploy branch:
main. The default branch was renamed frommaster, and a stalemasterstill exists on origin. It predateswebsite/, so a deployment left pointing at it sees a repo with no docs in it at all. - Content directory:
website/content. This is what makesdocs.jsondiscoverable given the placement above, and it is why every entry innavigation.groups[].pagescarries adocs/prefix — those paths are relative to the content directory, not todocs.json.
Two known gaps in the mirror, both inherent to serving one content tree through two renderers:
- Cross-page links render dead on Mintlify. The 23 internal links use the
./name.mdxform that Fumadocs'createRelativeLinkrequires; Mintlify wants extensionless root-relative paths. No single syntax satisfies both — fixing one breaks the other. - Screenshots 404 on Mintlify.
screenshots.mdxreferences/assets/*.png, served by Next fromwebsite/public/assets/. Mintlify resolves assets from its own content root and has nopublic/convention, so the images fall outside what it can see.
Adding a page means updating both content/docs/meta.json and docs.json — a page missing
from either is silently dropped from that site's sidebar.
Testing
- Unit tests (
tests/) - Vitest running via Electron runtime (ELECTRON_RUN_AS_NODE=1). Config invitest.config.ts(node environment, globals enabled). Covers database, services, store, types. - E2E tests (
e2e/) - Playwright with Electron. Config inplaywright.config.ts(30s timeout, 1 worker, trace on-first-retry). Requirespnpm buildfirst (handled bypretest:e2escript).
Code style
- No semicolons
- Single quotes
- No trailing commas
- 100 char print width
- 2-space indent
- TypeScript strict mode
- React JSX transform (no React import needed)
- ESLint 9 flat config with TypeScript ESLint + Prettier
- Prefer semantic theme tokens over raw color values in components (exceptions: overlays, status/severity colors)
Agent skills
Issue tracker
Issues live as GitHub Issues in thebristolsound/birdbrain, accessed via the gh CLI. External PRs are not a triage surface. See docs/agents/issue-tracker.md.
Triage labels
Five canonical triage roles using their default label strings (needs-triage, needs-info, ready-for-agent, ready-for-human, wontfix). See docs/agents/triage-labels.md.
Domain docs
Single-context layout: CONTEXT.md + docs/adr/ at the repo root. See docs/agents/domain.md.
Background jobs (project-local carve-out)
Unattended/background agent jobs working a ready-for-agent issue in this repo are opted out
of the global wait-for-confirmation rules: do not pause for mid-task approval and do not wait
for the user to confirm completion. Instead, verify the work (pnpm lint, pnpm typecheck,
BIRDBRAIN_REQUIRE_OPENSSL=1 pnpm test, pnpm build, plus pnpm build:extension when
extension/ changed), then finish by opening a draft PR with the standard attribution
line. Interactive sessions are not covered by this carve-out, and it must not be copied to the
global CLAUDE.md or other repos.
The gates in docs/adr/0005-unattended-agents-on-the-evidence-path.md still apply in full:
strict-serial WIP (max one open agent PR), human review on every agent PR through the pilot,
evidence-affecting PRs never auto-merge, and the give-up path (comment findings on the issue, relabel
needs-info/ready-for-human, vacate the slot) whenever the issue fails the ready-for-agent
bar at intake or mid-work.