page JS ──postMessage──▶ content script ──runtime.port──▶ background worker ──WS + token──▶ sidekick daemon
(untrusted) (ISOLATED world) (ORIGIN ORACLE, (ENFORCEMENT BOUNDARY;
▲ holds pairing token) owns key + MCP creds)
│ │
└───────────────────────── window.claude (MAIN world, no secrets) ◀────────────── events / responses ──┘
- The page never touches the daemon. It only has
window.claude, which postMessages to the content script. The daemon's loopback port is guarded by a pairing token the page never holds and by an Origin-header check that rejects web pages reaching localhost directly. - The background worker is the origin oracle. It derives each request's origin from
port.sender(the browser's truth), stamps it on theRequestEnvelope, and forwards it. A page cannot forge its origin because it never supplies one the daemon trusts. - The daemon is the enforcement boundary. Allowlists, tool classification, budgets, and per-action consent are checked here, out of band. The model runs inside the daemon and is handed a hard capability set; it can propose, never widen.
Every sensitive action — a page-initiated claude_callTool and a model-proposed tool call inside
the gated agentic loop — funnels through Gate.gateToolCall, fail-closed at each step:
- origin has a grant? → else
UNAUTHORIZED - tool in the origin allowlist? → else
SCOPE_EXCEEDED - rate budget ok? → else
BUDGET_EXCEEDED - class (default-deny): read → auto-approve · write → per-action user consent → else
CONSENT_DENIED - execute directly against the MCP server (creds never leave), audit, return.
Because step 4's write consent requires a human click pushed to the extension popup, no prompt injection can satisfy it — the model cannot click the button.
- Page calls
relay.stream({ prompt, agentic: true })→claude_stream. - Background stamps the verified origin → daemon.
- Daemon
assertCompletionAllowed(model in scope, rate + token budget). - Backend runs with
--allowed-tools = grant.tools(hard capability set). Text deltas stream back asdeltaevents keyed bystreamId. - When the model proposes a tool, the backend routes it through
ctx.gateToolCall→ the gate. A read auto-approves; a write pushes a consent prompt to the popup and blocks until the user clicks. The result (or denial) goes back to the model as a tool result — never bypassing. - On completion the daemon reconciles token usage against the budget and emits
done.
BackendRegistry routes a model id to a ModelBackend:
claude-code— the user's localclaudeCLI (their sign-in/subscription; no shared API key).local-openai— Ollama/LM Studio/llama.cpp via/v1/chat/completions.
The provider surface is identical regardless of backend — this is what lets one broker route "any local model or a Claude subscription," and is the seam a future entitlement/"app store" layer plugs into (metered per origin via the same budget ledger).
The gated agentic loop runs the @anthropic-ai/claude-agent-sdk query() in-process in the
daemon and wires its canUseTool(toolName, input) → {behavior:'allow'|'deny'} straight into
Gate.authorize. Proven end-to-end by packages/sidekick/spike/*.mjs:
gate-spike.mjs—canUseToolfires out-of-band for every proposed tool (MCP + builtins), is async (blocks on a human consent click), and enforces deny for MCP tools.mcp-spike.mjs— the realMcpRegistryconnects a real stdio MCP server, discovers + classifies its tools, and invokes one.integration-spike.mjs— the real backend through the realGate: granted read runs, granted write is consent-denied, ungranted tool is allowlist-blocked.e2e-daemon-spike.mjs— drives the running daemon over its WS like the extension would: pairing, a fullclaude_connectconsent round-trip,listGrants, a gated read call, a consent-denied write.
--permission-prompt-tool was removed in CLI 2.1.201; PreToolUse hooks were rejected because their
deny isn't enforced for MCP tools (gh #33106). bypassPermissions is never used (it would skip
canUseTool), and tools are never pre-approved via allowedTools (that would skip the gate).
Real: protocol types; origin stamping; grant store (persisted, narrowing-only); budget ledger;
default-deny classifier; the gate (authorize + execute paths); audit log; the Claude Code backend
via the Agent SDK + canUseTool; the MCP client (mcp/registry.ts — connects the user's
servers from ~/.relay/mcp.json, discovers/classifies/invokes, and feeds the SDK agentic loop);
the consent UI (connect-scope + per-action write windows) and the daemon control channel
(list grants, audit, revoke, kill); the SDK + provider.
Stubbed / honest gaps: the local-model tool loop (backends/local-openai.ts); MV3 worker
eviction during an open consent prompt (fail-closed to a denial for now — a durable prompt queue is
future work); the consent/control UI is proven on the daemon side headlessly but not yet in a live
loaded extension; classification pins are in-memory (not persisted).
One shared folder, everyone's own Claude. OFF by default behind a marker-file switch; every verb
is a panel-only control action (no protocol/SDK changes); the host's team listener is a SECOND
socket speaking only AES-256-GCM-sealed frames keyed from the invite secret — the extension's
loopback WS and the consent broker are untouched, and team peers are never extension sockets.
File-level LWW sync (Lamport clock + deviceId tiebreak, tombstoned deletes, scan-based detection)
converges members' folders byte-for-byte; wrapps see teammate changes through the existing
permissionsChanged re-read path. Design + threat model: docs/TEAMMODE.md;
proof: npm run try-team (two isolated daemons, the full grammar asserted headless).
The reference transport already prototypes an outbound "relay" mode where a hosted app forwards a job to the user's sidekick over HTTP (pairing token, session affinity via a single sidekick). It is noted, not led: the primary form factor is the pure client-side extension. The same daemon serves both.