Persistent project context for Claude Code. Read this first on every session. Equivalent to OpenCode's
AGENTS.mdpattern.
A browser-native AI agent harness, shipped as a Chrome/Firefox extension. The agent runs entirely in the user's browser. It talks to a model API directly (BYOK — bring your own key), drives the browser's tabs and DOM, and can run shell commands in a sandboxed Linux VM compiled to WebAssembly. No backend, no telemetry, no account.
peerd is 0.x — experimental beta (breaking changes likely; no "V1"
commitment — versions stay 0.x until the surface stabilizes). The
initial feature buildout is complete and integrated; current work is
store-readiness polish and field hardening. Stack:
Manifest V3, Chrome and Firefox (via webextension-polyfill), vanilla
JS, no
build step.
Each maps to one letter and color in the brand wordmark:
| Letter | Color | Module | Role |
|---|---|---|---|
p |
cyan | peerd-provider/ |
Model adapters (Anthropic, OpenRouter, OpenAI, Z.ai GLM, and keyless Ollama shipped; local WebGPU gated on the resident engine — registry.js is the live list) |
e |
red | peerd-egress/ |
Security: vault, allowlist (safeFetch), denylist, audit |
e |
amber | peerd-engine/ |
Execution instances — Sandboxes. Three kinds run in their own visible tab: WebVMs (CheerpX Linux), Notebooks (sealed JS worker + OPFS), Apps (opaque-origin iframe). A fourth, the headless worker (script), runs the Notebook's sealed worker offscreen with no tab — the agent's own quick compute. The sandbox is the isolate; a tab is one way to host it (taxonomy in the peerd-engine/ code). |
r |
green | peerd-runtime/ |
Agent loop, tools + per-environment actors (message_actor), sessions, profiles, skills, memory, permissions (Plan/Act), review, goal mode (autonomous loop), composer, cost, transfer, voice, clock, web tool policy |
d |
magenta | peerd-distributed/ |
The dweb. An always-on P2P base network (offscreen mesh + DHT + gossip), did:key identity, signed content addressing, the dwapp bridge, and a peer-to-peer app store that users AND the agent build, share, and run dwapps on. Preview channel only |
The extension chassis lives outside these modules: background/,
offscreen/, sidepanel/, engine-tabs/, permissions/, eval/,
shared/, tests/, vendor/, icons/. Each peerd-engine execution
kind owns a dedicated tab page under engine-tabs/ (engine-tabs/vm-tab/,
engine-tabs/notebook-tab/, engine-tabs/app-tab/) — grouped so the
three engine host surfaces sit together; permissions/ hosts user-gesture surfaces such as
the mic-permission grant page; eval/ is the live end-to-end eval
harness. (There is no content/ directory — DOM work happens via
injected functions, not a persistent content script.) Outside
extension/, the repo also carries signaling-node/ (the dweb
rendezvous server shells sharing the pure signaling reducer). The
peerd.ai site lives in its own repo now (NotASithLord/peerd-site); it
vendors snapshots of the VM-demo runtime + the peerd-distributed
transport, and the auto-update feeds are still generated here
(update-feeds/), then copied there to deploy.
The brand IS the architecture. If you find yourself adding a sixth top-level peerd-* directory, stop and reconsider.
The code is the spec. There is no separate design-doc corpus; the prose orientation is this file, and the rest is the source itself.
CLAUDE.md(this file) — orientation. Start here every session.- The module code —
extension/peerd-provider/,peerd-egress/,peerd-engine/,peerd-runtime/,peerd-distributed/. Each module'sindex.jsis its public surface; read it first, then the files it exports. - The code. For concrete behavior (vault crypto, denylist matcher, agent loop, tool dispatcher, prompt-injection defenses, the manifest, the MV3 keepalive trick), read the source in the relevant module. It is authoritative; prose drifts, code does not.
- Vanilla JS, ES modules, no build step. No bundler, no transpiler.
Code runs as the browser loaded it. The dev loop is load unpacked →
refresh. (The dual-distribution ARTIFACTS — store + preview — are
produced by
packaging/*.tsvia staging/pruning/zipping, not bundling;packaging/preflight.tsis the source of truth for the release checks.) - Generated files — don't hand-edit.
extension/manifest.jsonandextension/shared/channel-config.jsare generated bybun run gen:devfrommanifests/*.jsonandpackaging/default-settings.mjs. CI fails on drift. Versions live inpackage.jsononly. - Docs defer to code and CI for live state. Do not hard-code dynamic facts in prose: test/tool counts, gate matrices, release artifact lists, generated-file contents, extension IDs, channel behavior, provider/model inventories — and equally the things that quietly drift: tunable constants and thresholds (timeouts, caps, depths, retry code lists), exact dates/PR numbers as anything more than a passing anchor, and inventories of a directory's internal files (they grow with the code). Name the pattern and point at the source file / script / CI command that holds the real value; don't transcribe it. Static architectural invariants are fine; operational state belongs in code, generated output, or the release itself. When in doubt, describe the SHAPE and cite the file.
- The dweb boundary. Nothing outside
peerd-distributed/imports it — not even itsindex.js(stricter than the per-module rule below; the store package prunes the module entirely). Core code uses the types + stub inshared/dweb-interface.jsandloadDweb()fromshared/dweb-loader.js. Channel behavior flows only throughCHANNEL_DEFAULTS/DWEB_ENABLEDfrom/shared/channel-config.js— never a runtime channel probe, and never exposed to the agent or skills. - All JS runs under strict mode. ES modules are strict by default,
so no
'use strict'directive in module files (it would just be noise). Classic-script contexts get an explicit'use strict'at the top — currentlyextension/tests/bootstrap.js,extension/permissions/mic.js, and any function body injected viachrome.scripting.executeScript(the source is serialized and re-evaluated in the target page's classic-script world). If you add a new non-module entry point, the directive is mandatory. - No npm runtime inside the extension. Third-party code lives in
vendor/with aSOURCE.txtdocumenting where it came from and what version. Audit before vendoring. - Functional core, imperative shell. Reducers are pure. Policy steps are pure functions. IO is injected as a parameter, never imported directly inside a module. This is the testability lever.
- Mithril.js for UI. Already vendored. Don't swap frameworks.
- Three test surfaces, different jobs.
- In-browser at
extension/tests/runner.html(tiny custom framework). Tests that need a browser: DOM, chrome.*, real IDB lifecycle, side panel components, SW behavior, the voice transcribers. These catch real integration breakage. KEEP these as the extension grows -- don't migrate to Bun. They also run HEADLESS via the CDP harness (scripts/cdp/run-inbrowser-tests.mjs) — and CI runs that harness as its own job, alongside bun tests, ESLint, the strict typecheck of the bun suite, the dweb-boundary check, generated-file drift, and the 2×2 channel/browser artifact matrix. - Bun at
tests/**/*.test.ts(run withbun test ./tests). Tests for pure logic that can be exercised without a browser: registries (storage abstractions), the module resolver (source-transformation), other pure helpers. Fast (<1s), runnable from the terminal, good for solo-dev TDD. The suite is typechecked STRICT (bun run typecheck, a CI + preflight gate) — Bun itself strips types without checking, so the gate is what makes the annotations real.allowJspulls the extension's JSDoc typedefs into the check: tests double as drift detectors for the JSDoc contracts. The SAMEtscrun now also checks the extension itself, file-by-file via an opt-in ratchet:checkJsstays OFF at the config level, so an extension.jsfile is type-checked only once it carries a// @ts-checkdirective (unannotated files are still parsed for their JSDoc but their bodies aren't checked — no flag-day on a 72k-line no-build codebase).chromeis typed by@types/chrome;browser(the vendored webextension-polyfill) by the sidecarextension/vendor/browser-polyfill.d.ts— both dev-only, type-only, no runtime/build cost.bun run check:tscheckenforces a coverage FLOOR (count of// @ts-checkfiles) so the checked set only grows; the deliberately-ES5 injected bodies are never annotated and don't count. To add coverage: add the directive, make the file passbun run typecheck, bump the floor. Rule of thumb: if a test would mock half the world to run, it wants the browser. If it operates on values in and values out, it wants Bun. - Live E2E + the verify loop at
scripts/cdp/run-e2e-verify.mjs(bun run e2e:verify). Loads the REAL unpacked extension in Chrome for Testing and drives the live side panel through every "state" inscripts/cdp/states.mjs(smoke, goal mode, stop, error + visual snapshots) against ONE Chrome — the seam the other two surfaces can't reach (SW + port + vault + agent loop end to end; only the model wire bytes are faked, over CDP Fetch). It writesscripts/cdp/artifacts/(gitignored): a screenshot per state to LOOK at, a structuredresult.json(per-check pass/fail + the why), and a diff-highlight PNG on a visual miss. This is built for an AGENT to self-drive a change→verify→fix loop: edit,bun run e2e:verify, readresult.json+ the screenshots, fix, repeat untilok:true. (--functionalskips the visual states; CI runs that viatest:e2e:all.) - The visual lane has ONE baseline authority, and it is CI.
macOS and Linux cannot be pixel-compared — most of the panel's text
uses the system font stack, so the family changes per OS and
paragraphs re-wrap. So baselines are captured and compared only on
the pinned runner + pinned Chrome (
scripts/cdp/chrome-version.txt) and committed underscripts/cdp/baselines/<authority>/; thevisualCI job goes red when the render moves and uploads before/after/diff PNGs. A dev's run still captures and still diffs — against a gitignored self-baseline — but never gates, so the LOOK-at-it loop above is unchanged. Reseed deliberately: dispatch the workflow withupdate_visual_baselines, eyeball every PNG, then commit. Bumping the Chrome pin and reseeding belong in the SAME commit. Details + the measured numbers:scripts/cdp/visual.mjs.
- In-browser at
- UI work runs through the verify loop — never call a rendered change
done on assertions alone. When you touch a side-panel / home /
component surface, iterate edit →
bun run e2e:verify→ readscripts/cdp/artifacts/result.jsonANDReadthe screenshots → fix, untilok:true. Looking at the PNGs is mandatory, not optional: for new UI there's no baseline, so your eyes are the test; on a regression read the*-diff.pngto see what moved. Tighten the loop with--only=<state>or--visual. If you change a flow no state covers, ADD one toscripts/cdp/states.mjs(seed a new visual baseline withUPDATE_BASELINES=1 … --visualand commit it) — an uncovered UI change is an unfinished one. why: the unit tiers can assert structure but can't SEE the render; this loop is how an agent closes that gap on its own before pushing. index.jsis the public API per module. ESLintno-restricted-importsforbids deep paths from outside the module. Inside the module, deep imports are fine.- Comments explain why, not what. Every architectural choice
gets a
// why:comment. Code without rationale rots fast. - Modern, functional JS — lint-enforced.
eslint.config.js's "stylistic modernization" block makes the house style a gate (autofix witheslint extension --fix):const/letnevervar, arrow callbacks, template literals, object shorthand, spread,Object.hasOwn, array methods /for…ofover C-stylefor(;;)(a counting loop is fine for byte/codec work, reverse iteration, retry loops, or early-exit). Name things in full so identifiers read like the docs. Exception: the injected-into-page classic-script bodies (dom/walk-injected.js,dom/framework-state.js,dom/pull-in-hint-injected.js,background/debugger-pool.js,tools/defs/watch-changes.js) are deliberately ES5 —var,functionbound to a callerthis— and are exempted in the config. Don't "modernize" them. The same one-paragraph reminder rides thejs_create/app_createtool results (tools/defs/code-style-note.js) so the agent writes Notebook/App code to this bar too.
This was the original build order; it's kept because it IS the dependency graph (Layer 1 → Layer 2 → Layer 3) — everything below exists today:
- Chassis skeleton. Manifest, SW entry, offscreen doc, sidepanel shell. Get "load unpacked" working with an empty UI.
peerd-egress— vault, storage wrappers,safeFetch, denylist. Everything depends on this; build it first.peerd-provider— Anthropic + OpenRouter adapters. Schema conversions, streaming, error handling.peerd-engine— Sandboxes: four execution kinds (taxonomy in the module code). Three are hosted in their own visible tab — WebVM (CheerpX), Notebook (sealed JS worker + OPFS), App (opaque-origin iframe) — each with a registry inpeerd-engine, a runtime in its tab page underengine-tabs/(engine-tabs/vm-tab/,engine-tabs/notebook-tab/,engine-tabs/app-tab/), and a tab tracker + RPC client inbackground/. The fourth, the headless worker (script), runs the Notebook's sealed worker in the offscreen document with no tab (offscreen/job-runner.js) — the agent's own quick compute, same substrate as a Notebook, different host.peerd-runtime— agent loop, tool dispatcher, and the tool inventory. Registered tools are assembled fromBUILTIN_TOOLS, clock, web tools, and service-worker wiring; do not pin the live counts in prose. Exposure is decided intools/exposure.js: the low-level DOM/page tools are actor-only, never on the main agent — the orchestrator delegates plain-language goals to per-environment actors (web / webvm / notebook / app) viamessage_actor(actor/actor-messaging.js;actor_listenumerates every addressable handle), and replies re-enter it fenced on a later turn — it never blocks. The web actor (actor/web-actor.js) is the single entry point for web work: it picks between a sessionless, denylist-gatedfetch_urland opening + driving a tab. Dweb tools are invisible whereDWEB_ENABLEDis false. Plus sessions, clock (temporal grounding), actor orchestrator, voice (Moonshine WASM- Web Speech fallback).
- Wire it together in
background/service-worker.js— message routing, dependency injection, lifecycle wiring. The SW is wiring plus per-route message handlers (one per RPC the side panel / offscreen doc / tab pages dispatch in); the logic lives in modules. If a new handler needs more than a few lines of glue, push the logic INTO a module and keep the handler thin.
Already shipped (don't re-implement; extend instead). The code is the canonical catalog: read the relevant module before assuming something isn't built. The bullets here are the postures and gotchas to know going in:
- Anthropic provider with streaming, adaptive extended thinking on newer
models, prompt-cache breakpoints, retry on transient/overload statuses,
and the
anthropic-dangerous-direct-browser-accessack — plus an OpenRouter adapter (OpenAI-compatible gateway), a direct OpenAI adapter, a Z.ai GLM adapter (OpenAI-compatible direct endpoint), and a keyless Ollama adapter (local inference; live/api/tagsmodel inventory; GPU-fit model recommendation in Settings) (peerd-provider/). The shipped set is whateverregistry.jsregisters — don't take this list as authoritative. - Vault with passphrase unlock AND WebAuthn PRF (Touch ID / Windows
Hello) — same DK from either path (
peerd-egress/vault/). Idle auto-lock ON by default (user-settable); manual Lock button in the top bar. chrome.debuggerusage (CDP) — a CHANNEL-GATED required permission, NOT the default. Chrome forbidsdebuggerunderoptional_permissions(it's silently omitted), so where CDP ships it's required at install. It ships in the preview/dev channels — where CDP is the default automation path — and is STRIPPED from the initial store Chrome package and from every Firefox package (packaging/gen-manifest.tsSTORE_STRIPPED_PERMISSIONS; re-added to a store update post-approval — a one-line flip;docs/store/OPEN-DECISIONS.md§1). The DEFAULT path for the web actor's DOM tools on store-Chrome AND Firefox ischrome.scripting: a DOM-walk pseudo-a11y snapshot (peerd-runtime/dom/walk-injected.js) feeding the SAME serializer as CDP, with selector/walkIdclick/type and aworld:'MAIN'read_statefallback (peerd-runtime/dom/framework-state.js). Gating: the CDP pool is wired into a tool context only whenadvancedAutomationOn()=debuggerApiAvailable()(namespace present) AND theadvancedAutomationEnabledSETTING (default ON preview/dev, OFF store; Settings → Advanced). Genuinely CDP-only (correctly NOT faked):page_exec'sRuntime.evaluatewithallowUnsafeEvalBlockedByCSP: trueon Trusted-Types pages (Gmail/Notion/Slack), andpage_keys' trusted (isTrusted) input. Pool lives inbackground/debugger-pool.js.- Spawned actors — depth-bounded recursion, tool
narrowing, output cap. Real implementation at
peerd-runtime/actor/spawn.js— not a stub. Since the async-actor unification (PR #134): a child runs under its own turn slot with an abort signal and a wall-clock timeout (Stop andactor_cancelactually end its work, transitively down the subtree), and a trusted-lineage actor maymessage_actor— the sender gate walks server-stampedspawnedTrustedhops (actor/delegation-lineage.js; an inbound spawn taints its whole subtree), delegation budgets are keyed by the lineage root, and an actor's actor reply resolves into its tool result (an ephemeral child has no later turn to wake). - The heap split — EVERY non-orchestrator agent loop runs in its OWN
dedicated offscreen Worker heap (
peerd-runtime/actor/actor-worker-core.jsdrives it;offscreen/actor-worker.js+actor-runner.js+background/offscreen-actor-client.jshost + relay it). One substrate, two shapes: a BOUND actor (web/webvm/notebook/app, instance-pinned) and an EPHEMERAL actor (spawned — tool-less = pure reasoning, tool-bearing = a narrowed-general toolset). The worker holds NO key, NOchrome.*, NO engine clients; its only outward edges are two SW-gated relays — the model call (the SW addsgetSecret+safeFetch; the key never enters the worker) and every tool call (the SW rebuilds the caller's instance-pinned orgrantedTools-restricted ctx and re-checks it, NEVER trusting the worker's args). So the actor fence is a MEMORY boundary, not a prompt boundary: untrusted page/instance/response content stays behind the heap, one process-eviction from the vault DK no longer reachable. Chrome-only (needs the offscreen API); Firefox falls back to the keyless in-SW loop until it has one. why it matters: prompt injection has no filter — the fix is to never hand untrusted reasoning the authority in the first place. - Voice — local transcription via Moonshine (WASM, SRI-pinned model
download, OPFS-cached) with a Web Speech API fallback. Hosted in the
offscreen doc (
peerd-runtime/voice/). - Sandboxes — four execution kinds (taxonomy in the
peerd-engine/code). Three run in their own browser tab — WebVM (CheerpX), Notebook (sealed JS worker + OPFS), App (opaque-origin iframe) — each with its own registry + tab tracker + RPC client. The fourth, the headless worker (script), is the same sealed worker run offscreen with no tab, for the agent's own quick compute (code mode). - Policy-gated dispatcher with full lineage attached to every tool
result. The live stack is defined in
gates.jsplus the default pre/post tool-use hooks; keep prose at the invariant level so it does not drift. Current posture in code: Plan/Act enforcement, main-vs-actor exposure enforcement (the exposure + actor-capability-tier gates: actor-only tools refused for the main turn, each actor positively pinned to its own kind's toolset and instance), sensitive-origin blocking, async confirmation, egress enforcement in the allowlist hook and fetch wrappers, and append-only audit. The legacy permission-mode axis was REMOVED 2026-06-12 — Plan/Act + the denylist carry the safety weight; Plan permits pure URL loads only, never clicks (enforced ingates.js). - The feature buildout — memory, edit + checkpoints, Plan/Act,
composer (slash commands + @-refs), goal mode (autonomous loop), cost
telemetry, skills, review actor, and hooks — all integrated.
(do/get/check was CULLED: the web actor drives pages directly, so one
delegation reaches the page instead of two.) Per-feature
detail lives in the code under
peerd-runtime/. - Dual distribution: store (no dweb) + preview channels, generated
manifest.json/channel-config.js(bun run gen:dev).package.json,packaging/preflight.ts, and CI are the source of truth for release checks. peerd-distributed/— REAL code, well past the Phase 0 primitives (Ed25519 did:key identity, codec, content addressing + chunked signed-bundle transfer, the pure signaling reducer shared withsignaling-node/). Now live: an always-on base network in the offscreen doc (a WebRTC mesh introduced by a bootstrap node, gossip, presence, and a Kademlia DHT — the content directory); dwapps as namespaced sub-protocols on that shared mesh (commons rides it, no per-app rendezvous); a peer-to-peer app store (Share → Discover → Install over the mesh, no server); and the agent reaches all of it through thedweb_share/dweb_discover/dweb_installtools + the dwapp bridge (it builds p2p dwapps that users and agents both use). Preview-channel only (the store package prunes the module and CI verifies zero dweb traces). See thepeerd-distributed/code.- The dweb actor — a fifth bound-actor kind (
actorType:'dweb') and the first DAEMON actor: an opt-in (dwebAgentEnabled, preview-only, default off), persistent, GLOBAL singleton addressable asmessage_actor("dweb",…). It ABSORBS the dweb tools (they leave the orchestrator unconditionally —mainAgentDescriptorsdrops them by name + the tier gate refuses them for any non-actor ctx), runs keyless in the same offscreen heap as every actor, and MONITORS inbound mesh traffic: peers reach it on the reservedpeerd-agentroom, whosedirectevents wake it as fenced, INBOUND (untrusted) turns — the sender gate means an inbound message can never make it delegate. Rate-capped (per-did + global caps,background/dweb-inbound-rate-cap.js) before any model call; notable findings trickle up to the active chat as an attributed actor-reply bubble (runWhenIdle — never steals a live turn). The envoy for agent-to-agent over the mesh: "a peer's agent" is just an actor whose heap is on another machine. - Agent-to-agent (A2A) over the mesh — the dweb actor talks to other
agents by WRITING CODE, the #119 bet applied to p2p:
a2a_runruns JS against ameshclient (peers/card/ask/send/publishCard/inbox) in the SAME sealed keyless worker asscript, plus ONE capability — the mesh bridge — and nothing else (the host denies egress + actor-spawn for an a2a run; seeoffscreen/job-runner.js). The pure translation core isactor/a2a-api.js(the page-api.js twin:meshCallToOp/shapeMeshResult, aMESH_METHODStable); the ask/reply CORRELATION — tag a request DM, await the matching reply bound to the target did, time out — isactor/a2a-dispatch.js; the SW singleton + consent live inbackground/service-worker.js(a2aCallRoute). We RHYME with A2A's data model (peerd-distributed/agent-card.js— Agent Card, message shape) for future interop, but REJECT its HTTP+SSE transport: the mesh is the transport, did:key the address, the fenced inbound wake the stream. Signing ops (ask/send/publishCard) need per-target user consent, remembered and revocable viadweb_block; peer bytes arewrapUntrusted-fenced by construction. dweb-actor-only, preview-only (the store build prunes it).
Still ahead (backlog — not version-pinned. Don't front-run; let each land with deliberate design work):
- Profiles — per-profile vault namespacing, denylist, skills, memory, sessions; the default profile uses the same shape.
- Per-profile tool manifests — the per-SESSION layer already shipped
(the
/toolspresets +session.toolManifest;tools/manifests.js,tools/manifest-command.js, enforced ingates.js), on top of the registration/exposure split. What's still ahead is binding a manifest to a profile (rides the Profiles item above). Note: the per-environment actors are already trimmed to tight per-kind allow-lists by construction (tools/exposure.jsactorAllowedTools, enforced at dispatch ingates.js) — an actor never sees the full surface. - New provider adapters — the shipped set (Anthropic, OpenRouter, OpenAI,
Z.ai GLM, Ollama) lives in
peerd-provider/adapters/; adding one is a registry entry, not chassis wiring. The manifest still does NOT pre-declare hosts for adapters a given package doesn't ship (store policy: never request what the shipped version doesn't use);<all_urls>already covers HTTPS API hosts, and a loopback provider like Ollama needs its own CSP connect-src entry (manifests/base.json). - The dweb's next reach — A2A's first hop shipped (the
a2a_runcode surface above); still ahead is what rides it: standing multi-turn peer conversations beyond a single run, richer dwapps, and global discovery (find an agent by capability across the whole mesh, not just the present roster). The base network + signed direct channels + the Agent Card are in place; this is fleshing out what rides them.
- The brand color rule (owner direction, 2026-06-12): one rainbow
accent on monochrome. Surfaces are grayscale; the ONLY color
carriers are the five-color brand marks — the spinning orb
(spinners) and the wordmark letters (p cyan, e red, e amber,
r green, d magenta — on letterforms in terminal surfaces, on blocks
in the side panel). They mirror each other. Don't introduce other
accent colors; failure/error red is the one semantic exception.
Spinner cadence is brand-wide: 0.85s (was 1s) and proportional
elsewhere. One sanctioned accent moment: the composer's send disc
draws ONE random brand color per draft (mic→send morph,
SEND_ACCENTSin sidepanel/components/input-bar.js — owner experiment 2026-06-12; still never more than one color at a time). - Filenames: lowercase, hyphenated (
safe-fetch.js, notsafeFetch.js). - Exports: camelCase for functions and instances; PascalCase for classes and union members; SCREAMING_SNAKE for constants.
- Errors: named subclasses (
VaultLockedError,EgressDeniedError), not genericErrorwith a message. - Tests:
<file>.test.js, colocated with or under the file under test. - When in doubt, push functionality down the dependency graph, not across it. A module reaching sideways is usually a sign it doesn't belong where it is.