Skip to content

Internals

Contributing

Build topology, the checks, and how to add a capability.

3 min read Edit this page on GitHub

One edit, two builds, two reloads — and the checks that gate a pull request


Two Yarn projects

Separate lockfiles.

Extension Daemon + MCP
Root / /mcp
Bundler WXT (Vite) tsup
Output dist/chrome-mv3 mcp/dist
Stack React 19, Tailwind v4, shadcn/ui, zod Node ≥20, @modelcontextprotocol/sdk, ws, zod
Build yarn build yarn mcp:build

mcp/ imports lib/ through the @/ alias, which is how one registry ends up in two bundles.

node scripts/setup.mjs (yarn setup) runs all four steps — both installs, both builds — using the Yarn release vendored in the repository, so a fresh clone needs nothing on PATH but Node.


Commands

yarn dev              # build, launch a throwaway Chrome profile, hot reload
yarn dev:firefox
yarn build            # production build
yarn zip              # store-ready archive
yarn compile          # type check the extension
yarn mcp:compile      # type check the daemon
yarn mcp:dev          # rebuild the daemon on change
yarn mcp:restart      # rebuild, then swap the running daemon for the fresh build
yarn mcp:manifest     # print the tool manifest, no browser needed
yarn check:intent     # route a fixture table of utterances through the local grammar
yarn check:security
yarn check            # both type checks plus both fixture suites

Run yarn check before opening a pull request. If you touched the action registry, also run yarn mcp:manifest and keep reference/tools.md in step with what it prints.

The daemon keeps the old build in memory

The daemon has no start command: the first CLI or MCP client that needs it spawns it, and it lives until browsentic-mcp stop or 30 idle minutes with nothing attached.

The flip side is that a rebuild alone changes nothing while a daemon is running. That is what yarn mcp:restart is for: it rebuilds, stops the stale daemon and brings up the fresh build. The extension cannot spawn the daemon; it only reconnects to one.


Adding a capability

Write lib/actions/page/<name>.ts and add it to the array in lib/actions/registry.ts. That single edit publishes it as an MCP tool, because the daemon bundles the same registry.

Four conventions are load-bearing at runtime rather than at compile time:

  1. Touch document/window only inside execute() — the module is also imported by the daemon, where there is no DOM.
  2. No underscores in action names — they break the tool-name round trip.
  3. .describe() every input field — the text becomes the tool's JSON Schema documentation, and it is all the model gets.
  4. Validate with ActionError inside execute(), not zod .refine()/.transform() — those do not survive JSON Schema conversion.

Then rebuild both halves and reload the extension at chrome://extensions. Chrome does not auto-reload unpacked extensions, and a stale service worker is the usual cause of a drifted manifest.

If the capability is consequential

Add a rule to mcp/src/guardrails/policy.ts rather than a check inside the action — the policy is meant to be printable and diffable in one place. If it needs a new predicate, add it to CONDITIONS; the vocabulary is closed on purpose.

If it should be reachable from the side panel without an agent

Add a rule to lib/intent/grammar.ts and a fixture to the yarn check:intent table. Bias toward escalating — see the intent funnel.


Adding an agent runner

One file in mcp/src/agent/runners/ plus one line in runners/index.ts. The shared driver does the spawning, abort wiring and line reading; your runner decides what to say and how to read the answer back.

You must also add a CONTAINMENT entry in mcp/src/guardrails/spawn.ts declaring which containment mode that CLI supports and what its plan must carry. vetPlan() refuses to spawn a runner whose plan does not match — that is the point, and it is asserted in tests without spawning anything.


See also