Technical Plan — understudy: a governed browser-execution service (Chromium extension + Cloudflare session service)
understudy is a browser-execution SERVICE (substrate), not an agent app. It holds the user's live, logged-in browser sessions and exposes an HTTP command API; the LLM agent, the tool loop, and all governance (approval / RBAC / policy / audit) live in consumer applications that drive understudy over HTTP.
- understudy (this repo) = the Chromium extension + a Cloudflare service that terminates
the extension WebSocket, holds a CDP session per browser session, and serves
POST /v1/sessions/:sessionId/commands. It runs no LLM and embeds no agent framework. - Consumers =
proofoftech/metamind,smart-compliance, … — each its own repo. They own the Mastra agent + Claude, and wrap browser actions as@proofoftech/breakwaterconnectors gated by@proofoftech/flowsafeapprovals. Reference:smart-compliance/docs/examples/understudy-browser-connector.ts(mirrors metamind's shippedpackages/worker/src/intake/connectors.ts).
Everything below describes the substrate. Where an earlier revision had understudy running the agent loop / LLM / HITL internally, that work now lives in the consumer; this document reflects the substrate role.
- Repo:
proofoftech/understudy(its OWN repo — not merged intoproofoftech/anchorage, which is the libs-only monorepo shipping breakwater/flowsafe; every product is a separate repo consuming anchorage via published@proofoftech/*deps). As of 2026-07-26, the baseline is branchmasterat797d0e489df2772d0f5d597141982547861881bb. Reconfirm the branch and SHA before applying future edits. M0 through M4 are complete; M5 is largely complete with the remaining work listed in its milestone entry. - Main branch for PRs:
master. - Runtime targets decided: Cloudflare-first service; Chromium-only extension; multi-tenant;
CDP (
chrome.debugger) as the automation driver. Self-host (Node) of the service is a future target, kept open behind an interface — not built in v1. - Env quirks to know before building:
- Cloudflare Workers CPU limit is 5 min (paid), but I/O wait does not count. HTTP duration is unlimited while the client stays connected; a Durable Object stays alive while a WebSocket or pending I/O is in flight.
- MV3 service workers idle-die after ~30 s. An active WebSocket resets the idle timer; a
~20–25 s keepalive ping keeps the worker warm.
chrome.offscreenis the escape hatch for a truly durable connection. chrome.debuggerrenders a non-suppressible yellow "being debugged" banner on every tab while attached. Accepted; Chromium-only.- Cloudflare Browser Rendering / Managed Agents sandboxes are NOT usable for the attended path — they spin fresh cloud browsers with no access to the user's logged-in sessions. The architecture exists because attended automation must run in the user's real browser. (An unattended per-case managed browser is a deliberate future seam behind the same command API.)
- Terminology — "Agents SDK" = Cloudflare's
agentspackage (on Durable Objects). In the substrate it is used for exactly one thing: the per-session WebSocket DO (WS-hibernation handling + per-session state). It is NOT Anthropic's Managed Agents and NOT the Claude Agent SDK. Consumers, not understudy, run the LLM agent (they use Mastra). - Verify fast-moving facts before changing these surfaces. Written 2026-07-13. Re-confirm
against current docs: (a) the
chrome.debuggerallowed-CDP surface [M0 evidence is historical]; (b) the Agents SDK API (Agent,routeAgentRequest,onConnect/onMessage,setState) + Workers/DO limits; (c) breakwater/flowsafe APIs at the consumer boundary (thePOST /commandscontract +fill_secret). - understudy runs no LLM — there is no
claude-apiprerequisite here. Model choice (Claudeclaude-opus-4-8is the consumers' default;deepseek-v4-prowas considered and rejected — not referenced by breakwater/flowsafe, not verifiably a real model) is the consumer's concern. If a thin first-party demo agent is ever built, it uses Claude via Mastra. - Cloudflare build: lean on the
cloudflare:agents-sdk/cloudflare:workers-best-practices/cloudflare:wranglerskills.
| # | Decision | Rationale | Rejected |
|---|---|---|---|
| D1 | Extension is the automation driver; the service is a substrate, not a brain. | Only the user's real browser has their logged-in sessions. The service routes commands to the session's browser and returns events. | Headless Playwright/Puppeteer on server; CF Browser Rendering — no user login state. |
| D2 | CDP via chrome.debugger for read + act. |
Playwright-grade fidelity (a11y tree, precise input, cross-frame) in the live logged-in tab. | Content-script-only (can't reach cross-origin iframes / native dialogs / some events). Hybrid is a later evolution. |
| D3 | Chromium-only (Chrome, Edge, Brave, Arc). | chrome.debugger/CDP is Chromium-only. Forced by D2. |
Firefox (no CDP) / Safari (separate build + review). |
| D-TOP | understudy = browser-execution SERVICE; consumers own the agent + governance. | Matches the shipped metamind/smart-compliance pattern; keeps the substrate framework-light and reusable by many governance apps; decouples understudy from anchorage. | understudy internalizing Mastra/breakwater/flowsafe — duplicates consumer work, is heavier, and framework-couples the substrate. |
| D4 | Service = Hono (HTTP front door) + Cloudflare Agents SDK per-session DO. | Hono serves the command API + session/auth/health. The Agents SDK DO gives per-session WS + hibernation + state — the live session layer, pre-built. | Hono alone (no stateful WS session); flowsafe's DOs (per-run HTTP workflow objects, no live WS — verified: do-runner/durable-object.js is an async fetch router with zero WebSocket); raw DO (viable, ~100 lines more — D4-alt). |
| D4-alt | If minimizing deps: raw Durable Object in place of the Agents SDK session DO. | The Agent class is a DurableObject; the swap is localized to the session-DO file. |
— |
| D5 | Portable substrate core (packages/protocol + the command/session service behind a SessionCoordinator interface) is runtime-agnostic plain TS. |
Lets a Node self-host of the service be added later (a second SessionCoordinator impl) instead of a rewrite. The extension is already service-agnostic (configurable WS URL). |
Coupling the session/command core to the Agents SDK class (would block self-host). |
| D6 | No LLM / agent loop inside understudy. | The brain executes remotely against understudy's command API from the consumer; the model, the tool loop, and prompt handling are the consumer's. understudy stays a clean, model-free substrate. | Embedding a Claude loop (or Mastra agent) in the substrate — re-introduces the coupling D-TOP removes. |
| D7 | Element targeting = accessibility-tree snapshot with stable ref IDs. |
Robust vs. brittle CSS/XPath. Same model Playwright / chrome-devtools-mcp use. Consumer picks a ref; extension resolves it to a live node. |
Raw selectors (brittle). |
| D8 | Governance (HITL approval on writes, RBAC, policy, audit) lives in the CONSUMER via breakwater + flowsafe (SoD, one approval queue + one audit trail). | A browser write is just another privileged side effect; wrapping it as a breakwater connector puts it through the same governance path as any API connector. understudy provides the hooks governance needs (an egress-guardable command API, dryRun, fill_secret), not the approval UX. |
understudy's own HITL (a parallel approval stack; no SoD; a second audit trail). |
| D-SEC | Secrets never reach the model: fill_secret{secretRef} resolved service-side against a vault. |
The agent passes an opaque secretRef (vault://…); understudy resolves it (CF Secrets / per-tenant KMS) and types the plaintext. Plaintext never enters the connector input, model context, audit detail, or the flowsafe snapshot. |
Routing passwords through type{text} — leaks plaintext into all four. |
| D-PUB | @understudy/protocol is published (zod 4) as the shared contract consumers import. |
Consumer connectors import { A11yNodeSchema, parseCommand } from '@understudy/protocol'; they nest its schemas in zod-4 objects, so the protocol must be zod 4 (zod-3-in-zod-4 fails). |
Keep it internal/unpublished (consumers couldn't import it); rename to @proofoftech/… (churns shipped consumer imports for cosmetics). |
| D9 | TypeScript everywhere; pnpm workspaces monorepo (this repo). | Workers, MV3, and the protocol are all JS-native; one language across protocol/service/extension. | Rust/Python — no benefit, extra friction. |
| D10 | Extension build with WXT; service deploy with Wrangler. | WXT is a modern MV3 framework; Wrangler is the CF standard. | Plain Vite + CRXJS (viable if WXT friction appears). |
┌──────── CONSUMER app (metamind / smart-compliance — separate repo) ─────────┐
│ Mastra agent (Claude) + @proofoftech/breakwater connectors │
│ observe / act / fill_credential ── createConnector → createTool │
│ write-action → flowsafe approval gate (SoD) → grant on resume │
└───────────────────────────────┬──────────────────────────────────────────────┘
egress-guarded runtime.fetch │ POST /v1/sessions/:sessionId/commands
▼ { command, dryRun? } → Event JSON
┌───────────────────── UNDERSTUDY SERVICE (Cloudflare Worker) ─────────────────┐
│ Hono: /v1/sessions/:id/commands · session mgmt · /auth · /health │
│ SessionAgent extends Agent (one Durable Object per SESSION, per tenant/case)│
│ • terminates one authoritative extension WebSocket and tracks session state│
│ • coordinator.send(cmd) ─ WS ▶ extension ─ Event ▶ resolves pending map │
│ • fill_secret: resolve secretRef (vault) → type plaintext service-side │
└───────────────────────────────┬──────────────────────────────────────────────┘
wss://<service>/agents/session/:sessionId (auth token)
▼
┌───────────────────── User's real Chrome (Chromium) — the EXTENSION (M2) ─────┐
│ Background SW: WS client · keepalive · command router · CDP session manager │
│ Side Panel (React): status / attach-detach [ chrome.debugger ] │
│ driver/cdp.ts: snapshot / click / type / navigate / key / scroll / wait │
└───────────────────────────────────────────────────────────────────────────────┘
The LLM loop, model choice, approval UX, RBAC, policy, and audit are all in the consumer. understudy's job is faithful, isolated, auditable command execution against the live session.
understudy/
package.json # pnpm workspaces root
pnpm-workspace.yaml
packages/
protocol/ # PUBLISHED @understudy/protocol (zod 4): Command/Event unions + schemas.
src/index.ts # Shared contract, including fill_secret, imported by consumers.
connector/ # PUBLISHED @understudy/connector (M4): reference breakwater connectors
src/index.ts # (observe/act/fill_credential) consumers import as Mastra tools.
apps/
backend/ # Cloudflare Worker — the browser-execution SERVICE (M3)
src/index.ts # Hono app: POST /v1/sessions/:id/commands, session mgmt, auth, health
src/session.ts # SessionAgent extends Agent (per-session DO) | raw-DO swap (D4-alt)
src/coordinator-cf.ts # SessionCoordinator over the session WS + pending-promise map
src/secrets.ts # fill_secret: secretRef → vault resolve → dispatch (plaintext never logged)
src/auth.ts # caller (consumer) auth + tenant/session scoping
wrangler.jsonc
extension/ # Shipped M2 MV3 Chromium extension built with WXT
src/entrypoints/background.ts # SW: WS client, keepalive, command router, CDP session manager
src/entrypoints/sidepanel/ # React: status, attach/detach (no task/approval UI — that's consumer-side)
src/driver/cdp.ts # CDP executors: snapshot/click/type/navigate/... (+ dry-run seam)
wxt.config.ts
# apps/backend-node/ # FUTURE self-host of the SERVICE: reuses protocol + SessionCoordinator
# The agent loop, Claude client, and tool wiring are CONSUMER code (metamind /
# smart-compliance), NOT here. understudy PUBLISHES the reference connector
# (packages/connector -> @understudy/connector) for consumers to import, but does not
# run it - no breakwater/flowsafe/Mastra code executes in this repo's apps.
The stable IP: shared across the service, the extension, AND consumer connectors; browser-agnostic;
published as @understudy/protocol on zod 4. Every message is a discriminated union tagged by
type and is validated with zod at every boundary. Commands and their correlated results carry a
commandId; connection, page, and heartbeat events do not.
Target contract. The union below is the current public wire shape. Protocol 0.6 makes the snapshot target fields mandatory, so the service and extension must upgrade in the same rollout.
// Consumer/Service → Extension
export type Command =
| { type: "snapshot"; commandId: string; mode: "a11y" | "dom" | "screenshot"; tabId?: number }
| { type: "navigate"; commandId: string; url: string; tabId?: number }
| { type: "click"; commandId: string; ref: string }
| { type: "type"; commandId: string; ref: string; text: string; submit?: boolean }
| { type: "key"; commandId: string; keys: string; ref?: string }
| { type: "scroll"; commandId: string; ref?: string; dy: number }
| { type: "wait"; commandId: string; for: "load" | "idle" | "ms"; value?: number }
| { type: "resolve_ref"; commandId: string; ref: string }
| { type: "get_tabs"; commandId: string }
| { type: "switch_tab"; commandId: string; tabId: number }
| { type: "fill_secret"; commandId: string; ref: string; secretRef: string; submit?: boolean };
// Extension → Service
export type Event =
| { type: "hello"; browser: string; extVersion: string; tabs: TabInfo[] }
| { type: "snapshot_result"; commandId: string; tree: A11yNode[]; tabId: number; url: string }
| { type: "screenshot_result"; commandId: string; mime: string; b64: string; tabId: number; url: string }
| { type: "action_result"; commandId: string; ok: boolean; error?: string; url?: string; simulated?: boolean }
| { type: "tabs_result"; commandId: string; tabs: TabInfo[] }
| { type: "page_event"; kind: "navigated" | "load"; tabId: number; url: string }
| { type: "dialog"; tabId: number; dialogType: "alert" | "confirm" | "prompt" | "beforeunload"; message: string; url: string; defaultPrompt?: string; disposition: "accept" | "dismiss" }
| { type: "pong" };
export interface A11yNode { ref: string; role: string; name?: string; value?: string; children?: A11yNode[] }
export interface TabInfo { tabId: number; url: string; title: string; active: boolean }Design notes:
refis the only thing the consumer's agent ever uses to address an element. Its encoding is opaque. The extension binds it to the extension session, exact CDP attachment, and snapshot generation that minted it, then resolves it locally (see targeting).- Snapshot results include the exact attached CDP
tabIdand main-frameurlcaptured with the artifact. Consumers validate those fields instead of inferring the session target fromTabInfo.active, which is true once per browser window. An optional commandtabIdis an expected attachment and fails closed on mismatch. A ref also fails closed after another browser connection or tab replaces the attachment, even if both tabs minted the same generation number. fill_secretis an agent↔service command. The consumer'sfill_credentialconnector sendsfill_secret{secretRef}; the service resolvessecretRefagainst the vault and drives the keystrokes into the session (over the trusted service↔extension hop, reusing the extension's existing type path — the extension never reaches the vault, and never seesfill_secret). The plaintext lives only transiently in the service and on that last hop; it never enters the connector input, the model context, the auditdetail, or the flowsafe snapshot (D-SEC).dryRunrides on the service API ({command, dryRun?}), not the union. The M3 service is fail-safe: ondryRunit does a read-onlyref-resolution check via theresolve_refcommand (a pure lookup against the extension's live ref map — NOT a snapshot: the extension re-mints every ref per snapshot, so a snapshot probe can never contain the consumer's ref and invalidates all outstanding refs, breaking the approved command after the simulation; this was the original M3 dry-run bug, caught by the attended e2e 2026-07-16) and returns a simulatedaction_result— it never dispatches a mutating command and never resolves a secret (a "simulation" must never execute an unapproved write, or it defeats the consumer's approval gate).resolve_refIS the extension-native resolve-without-dispatch probe once deferred as a "later M2 amendment" — landed with the fix. A dry-runokguarantees resolvability (the ref maps to a live node in the current generation), not executability (e.g. box-model availability at dispatch time).resolve_refis an internal service↔extension probe; consumers express dry-run intent via the service API'sdryRunflag, never by sendingresolve_refthemselves.commandIdcorrelates the async round-trip: the service'ssend(cmd)returns a promise parked in aMap<commandId, resolver>; the matching*_resultevent resolves it. The active request and timeout prevent DO hibernation mid-command. A shutdown or restart settles through the timeout/resync path, and the persisted awaiting marker reconciles a late result.tabs_resultanswersget_tabswith acommandId-bearing event (added at M2).- Published runtime exports (what consumer connectors import):
parseCommand/parseEvent(throwing) andsafeParseCommand/safeParseEvent;CommandSchema/EventSchema,A11yNodeSchema,TabInfoSchema,SnapshotModeSchema,SnapshotTargetSchema,DialogTypeSchema,DialogDispositionSchema,DialogRecordSchema;isWriteCommand+WRITE_COMMAND_TYPES(the single write-classification source of truth downstream layers derive from — the connector pins its gated union to it at compile time); and the typesCommand/Event/WriteCommandType/A11yNode/TabInfo/SnapshotMode/SnapshotTarget/DialogType/DialogDisposition/DialogRecord. - Write retries are idempotent end to end (M5): the connector derives a write's
commandIdfrom the breakwater idempotency key (ik_<key>); the service records completed write Events per session (completedWrites, cap 100) and replays a repeated commandId instead of re-dispatching (409 for a concurrent duplicate still in flight); the extension keeps its own replay + in-flight record (cap 100, storage.session) covering BOTH the service-timed-out- after-execution case (replay) and the service-timed-out-mid-execution case (drop the duplicate, never re-run). This closed the documented "write performed, response unparseable → retry re-executes" gap. The write class here is the protocol'sWRITE_COMMAND_TYPES— which now includesscroll/switch_tab(user-visible side effects: a dry-run simulates them and a retry replays them, so a relative-dyscroll never double-scrolls).
The hard problem. Approach (matches Playwright / chrome-devtools-mcp):
- Build snapshot:
Accessibility.getFullAXTree(+DOMforbackendNodeIdlinkage). Prune to actionable/meaningful nodes (buttons, links, inputs, headings, text). Assign each a stablerefwhose opaque namespace binds the extension session, CDP attachment, and snapshot generation, then map it to the CDPbackendNodeId. Keep theref → backendNodeIdmap in the SW for the current attachment and snapshot generation. - The consumer's agent sees the pruned tree as compact indented text (role + name + ref), not raw HTML — far fewer tokens, far more reliable targeting.
- Resolve on action:
click{ref}→backendNodeId→DOM.getBoxModel→Input.dispatchMouseEvent(press+release at center), ORRuntime.callFunctionOn.click()when geometry is unreliable.type{ref}→ focus node thenInput.insertText/Input.dispatchKeyEvent. - Staleness: a
refis valid only for the extension session, CDP attachment, and snapshot generation that produced it. Replacing the browser connection or attached tab invalidates every outstanding ref, including refs whose generation and element sequence happen to collide. A stale ref returnsaction_result{ok:false, error:"stale ref"}; the consumer re-snapshots and retries. - Screenshots (
mode:"screenshot"→Page.captureScreenshot) are a fallback the agent can request when the a11y tree is insufficient (canvas, visual layout). DOM/a11y-first keeps cost down.
chrome.debuggerexposes the CDP domains needed here (Accessibility, DOM, Input, Page, Runtime). Milestone 0 confirmed this surface on 2026-07-13 — see "M0 findings".Target.getTargetsis restricted underchrome.debugger("Not allowed"); tab enumeration/switching is backed by the WebExtensionschrome.tabs.*API (not CDP), and per-tab attach useschrome.debugger.attach({ tabId }). This was the one real technical risk of the CDP approach; it is now retired. The one sub-question it deferred — cross-origin-iframe (OOPIF) auto-attach viaTarget.setAutoAttach{ flatten: true }— probed green on 2026-07-17 (Chrome 150): the iframe attached and was driven through its ownsessionId(see "M0 findings").
Probe run via apps/cdp-spike on a real logged-in Chromium tab (a signed-in Google session — the
a11y tree exposed the account holder, confirming CDP reads the actual logged-in page, not a fresh
context — the entire premise of D1). 10/11 CDP commands OK; the a11y ref pruning produced usable
targets; and the full attach → snapshot → screenshot → detach cycle completed with a clean detach. The
CDP targeting approach (D2/D7) is validated; the plan's single biggest technical risk is retired.
CDP command surface:
| CDP command | Result |
|---|---|
Accessibility.enable / getFullAXTree |
OK — ~2024–2060 nodes on a live page |
DOM.enable / getDocument / getBoxModel |
OK — getBoxModel via backendNodeId returned a box (1710×978); ref → coordinates works |
Runtime.enable / evaluate |
OK |
Input.dispatchMouseEvent |
OK (no-op move) |
Page.enable / captureScreenshot |
OK — ~235 KB PNG |
Target.getTargets |
FAIL — { code: -32000, message: "Not allowed" } (restricted under chrome.debugger) |
a11y ref model (D7): pruning the full tree yielded 162 actionable nodes out of 2060 (~8%), each a
{ ref, role, name } with a human-meaningful name — the search combobox, real button labels (Share,
Google apps), and links (Page 2…5, Go to Google Home). This is exactly the compact shape a consumer
feeds its LLM. D7 confirmed end-to-end.
Impact of the one failure: none on the design. Target target-discovery methods are blocked under
chrome.debugger by design (a privilege-escalation guard):
- Per-tab attach uses
chrome.debugger.attach({ tabId })with a WebExtensions tab id — never CDPTarget.attachToTarget.getTargetswas never on the attach path. - Multi-tab awareness (
get_tabs→tabs_result,switch_tab) is backed bychrome.tabs.*(chrome.tabs.query,chrome.tabs.update(tabId, { active: true })). Protocol commands are unchanged; only their extension-side implementation is pinned here. - v1 scope is a single designated tab/session anyway (see "Out of scope").
One sub-question deferred at M0, probe RUN GREEN at M5 (2026-07-17): cross-origin /
out-of-process iframe (OOPIF) traversal via Target.setAutoAttach{ flatten: true } (a different
Target method than the blocked getTargets, and — now confirmed — permitted under chrome.debugger).
apps/cdp-spike carries a focused OOPIF probe (side-panel button + bundled
oopif-test.html cross-origin-iframe page + runbook in its README): it runs setAutoAttach,
collects Target.attachedToTarget events, and drives each attached target through session-scoped
sendCommand({tabId, sessionId}, …) (Runtime.evaluate + Accessibility.getFullAXTree).
Verdict: PASS — auto-attach works, session routing works; the deferred unknown is retired. Run
2026-07-17, attended, on Chrome 150.0.7871.114 (64-bit) (≥125), against the bundled oopif-test.html
(a file:// top frame embedding a cross-site https://example.org iframe). Probe summary verbatim:
"OOPIF auto-attach WORKS: 1 iframe target(s) attached and driven via session-scoped commands." Evidence
it drove the iframe, not the top frame:
Target.setAutoAttach { flatten: true }— OK (the target-discovery capabilitygetTargetsis blocked for is permitted here);Target.attachedToTargetannouncediframe https://example.org/;Runtime.evaluatevia the iframe'ssessionIdreturnedhttps://example.org/— not thefile://…/oopif-test.htmltop-frame URL;Accessibility.getFullAXTreevia thatsessionIdreturned the iframe's isolated 15-node tree (heading:Example Domain, link:Learn more), without the top frame'stop-frame button— which the top frame's own 34-node tree did contain. Same tab, twosessionIds, two isolated frames driven independently: exactly the session routing the real driver needs.
The same run re-confirmed the M0 baseline unchanged — 10/11 CDP commands OK, Target.getTargets still
{ code: -32000, message: "Not allowed" } — so the restriction that motivated the deferral persists and
setAutoAttach sidesteps it. Cross-frame driver work in apps/extension is now unblocked, but stays
gated on a consumer actually needing cross-origin-iframe targeting — the single-frame path remains the
proven default; nothing is built on OOPIF until a consumer requires it.
This is CONSUMER code (in metamind / smart-compliance), documented here because it defines understudy's
outward contract. Canonical reference: packages/connector
(@understudy/connector@0.4.0, M4), tested against the shipped M3 service contract
(bearer caller auth and @understudy/protocol@0.6.0 target-bound snapshots and refs).
The historical sketch it grew from,
smart-compliance/docs/examples/understudy-browser-connector.ts, predates M3 (no
caller auth, local fill_secret shim) and carries stale pre-Topology-1 prose; prefer
the package.
- A browser action is wrapped as a breakwater
createConnector()(three connectors:observe= read/no-approval,act= write/discriminated-union,fill_credential= write/vaulted). Each connector'sexecute()calls understudy via the egress-guardedruntime.fetchatPOST {UNDERSTUDY_URL}/v1/sessions/:sessionId/commands, then validates the returnedEventwith@understudy/protocol. - Governance the consumer gets for free (understudy provides the hooks, not the mechanism):
permissions.requiresApproval→ the write is DENIED unless the request carries a flowsafe-minted grant inrequestContext['breakwater.approvedConnectors']; flowsafe suspends the run at an approval gate, an independent analyst approves (SoD), then the connector executes — one approval queue, one audit trail, not understudy's own HITL.permissions.egress→runtime.fetchis pinned to the understudy host; anything else is denied + audited.permissions.idempotencyKey→ at-most-once across DO hibernation/retries via D1 stores (in-memory defaults are per-isolate = per-run under flowsafe → would double-submit; useD1IdempotencyStore/D1RateLimitStore). A resumed step never double-submits a form.permissions.dryRun→ "propose the action, don't dispatch it" (plan→review→execute).permissions.rateLimit→ bounds runaway automation per connector/tenant.
- Grants mint for STATIC connector ids (
browser.act,browser.fill_credential) at the flowsafe suspension — never derived from model output (a grant is a capability token). - Model choice is the consumer's (Claude
claude-opus-4-8default). understudy is model-free.
SessionAgent extends Agent(Agents SDK). One DO per session (per tenant/case), addressed bysessionId— not one per user. It terminates the extension WebSocket (wss://.../session/:sessionId), holds the CDP session mirror (theSessionCoordinator), and tracks the refMap generation.POST /v1/sessions/:sessionId/commands(Hono): body{ command, dryRun? }→ route to that session's DO →coordinator.send(command)→ await the correlatedEvent→ return it as JSON. ThesessionIdselects an isolated per-tenant/per-case browser context (attended extension WS now; unattended managed browser is a future seam behind the same route) — never a shared profile.SessionCoordinator(interface in the portable core, impl incoordinator-cf.ts):CF impl:interface SessionCoordinator { send(cmd: Command): Promise<Event>; // WS send + await matching commandId setStatus(s: SessionStatus): void; // → this.setState (SQLite + broadcast) }
sendwrites to the WS and parks a resolver in an in-memoryMap; also persist the "awaiting commandId" marker viathis.setStateso a mid-command hibernation wakes knowing a command was outstanding and re-requests a snapshot rather than deadlocking. There is norequestApprovalhere — approval is the consumer's (flowsafe).fill_secret(secrets.ts): resolvesecretReffrom the vault (CF Secrets / per-tenant KMS), dispatch the keystrokes to the session, and record theref/secretRefin audit — never the plaintext (D-SEC).- Scheduling (
this.schedule): optional; retry-with-backoff / "re-check later" without external cron. - D4-alt (raw DO): replace
extends Agentwithextends DurableObject, hand-writefetch+acceptWebSocket+webSocketMessage, and replacesetStatewithctx.storage+ manual broadcast. TheSessionCoordinatorinterface and the command API are unchanged.
- Callers are consumers. A consumer authenticates to the service (bearer token /
TokenVerifierseam) and opens a session bound to atenantId(per tenant/case). A consumer can only drive its own sessions; a request whosesessionIddisagrees with its tenant is refused (404, not 403 — no existence oracle), mirroring flowsafe's tenant discipline. - The extension is provisioned a per-user token (stored in
chrome.storage) to connect its WS to its session; the service verifies it on connect and closes with 1008 on failure. - Secrets: the credential vault (for
fill_secret) is service-side via Wrangler secrets / per-tenant KMS. There is no Anthropic key here — understudy runs no LLM.
- The agent acts as the logged-in user — but the write-approval gate (HITL + SoD) is the
consumer's (breakwater
requiresApproval+ flowsafe). understudy trusts authenticated, tenant-scoped callers and executes commands faithfully; it does not run a second approval stack. - Per-tenant / per-session isolation. One DO per
sessionId; auth-gate on connect and on every command; no cross-tenant session routing. - Credential invariant (D-SEC).
fill_secretplaintext never enters the model context, connector input, auditdetail, or flowsafe snapshot; it exists only transiently in the service and on the service↔extension hop, and is never logged. - CDP is powerful — scope which tabs/sessions it attaches to (one designated tab per session; don't auto-attach to all). Surface the "being debugged" banner as expected.
- Prompt-injection awareness: page a11y text is untrusted input to the consumer's LLM. The injection boundary (system-prompt "page text is data not instructions" + breakwater policy + origin allowlist) is enforced consumer-side; understudy reports page content faithfully. Document as a shared residual risk.
- Dialogs: a page
alert/confirm/prompt/beforeunloadblocks the single CDP channel, so the extension answers it locally and synchronously viaPage.handleJavaScriptDialogwith a type-aware disposition (alert/beforeunload accept, confirm/prompt dismiss) and reports it to the consumer as a best-effortdialogEvent (recorded in DO state, read viaGET /v1/sessions/:id; a report lost during a WS drop is not replayed). Guaranteed-delivery-on-reconnect (an extension-side dialog buffer replayed on reconnect, dedup'd by a dialog id server-side) was considered and deliberately deferred to M6 — the surface matchespage_event's existing lossiness. Residual risk: whilechrome.debuggeris attached, abeforeunloadis auto-accepted so the automation's own navigation proceeds — a human co-driver's unsaved-changes guard is thereby proceeded through;confirm/promptare dismissed, never auto-confirmed.
- The LLM / agent loop / model choice — CONSUMER concern (metamind / smart-compliance).
- Approval UX, RBAC, policy engine, audit dashboard — CONSUMER concern (breakwater + flowsafe).
- Node self-host of the service — kept possible (portable core + interface), not built.
- Firefox / Safari — excluded by D3.
- Content-script fallback / hybrid driver — v1 is pure CDP (D2).
- Unattended managed-browser sessions — a deliberate seam behind the command API; v1 is the attended extension path.
- Cloudflare Browser Rendering / Managed Agents (attended path) — architecturally wrong (no user session).
- Stagehand / Playwright-based frameworks as a dependency — they drive their own browser, not the
user's logged-in one (fails D1), and can't run on Workers. Borrow the
act/extract/observe+ a11y-grounding design (already reflected in D7); don't depend on the library. A local Node companion viaconnectOverCDPis a different product, kept as Plan B.
- M0 — CDP spike. ✅ DONE (2026-07-13) — 10/11 CDP commands OK; only
Target.getTargetsrestricted (non-blocking); a11y ref-model pruning validated (162/2060 actionable). See "M0 findings". - M1 — Protocol package. ✅ DONE —
@understudy/protocolzod schemas + types; round-trip tests. Follow-ons:tabs_result+ the zod 3→4 bump landed in M2 (the extension bundles zod 4);fill_secretand publishable package metadata landed in M3; the release pipeline and first npm publication landed in M4. - M2 — Extension driver. ✅ DONE — SW holds a WS to the service;
cdp.tsimplements snapshot/click/type/navigate/key/scroll/wait; command router maps protocol → CDP; 20–25 s keepalive +chrome.alarmsbackstop. The extension bundles protocol with zod 4.dryRunis enforced by the service before dispatch, andfill_secretremains service-side. - M3 — Browser-execution service. ✅ DONE — Hono + Agents-SDK per-session DO;
routeAgentRequest;POST /v1/sessions/:sessionId/commands(withdryRun); the pending-map + hibernation-resume marker;fill_secretvault resolution; caller auth + per-tenant/session scoping. Automated service tests cover WebSocket connection, command dispatch, timeout/resync behavior, tenant isolation, and credential handling. - M4 — Consumer integration + published contract. ✅ DONE —
@understudy/protocol(zod 4) and the reference breakwater connector@understudy/connectorare published. Metamind drove Understudy end-to-end through its attended flowsafe browser-enrichment workflow and breakwater connectors. Cross-repo; understudy's deliverable is the published contract + reference connector, not the consumer workflow. Status (2026-07-26): COMPLETE — observe (snapshot/get_tabs/get_dialogs/wait) / act (click/type/navigate/key/scroll/switch_tab, grant-gated) / fill_credential (vaulted), egress-pinnedruntime.fetch, caller bearer auth, and tests against the real breakwater wrapper (fail-closed grant, idempotent replay, per-hop egress denial, dry-run).@understudy/protocol@0.6.0and@understudy/connector@0.4.0add target-bound snapshots and refs and are published on npm with MIT-licensed,files-scoped tarballs and npm provenance. The changesets + GitHub Actions release flow is wired in.github/workflows/release.yml. Metamind contains the consumer-side Mastra workflow, flowsafe approval, breakwater connector wiring, automated coverage, and attended runbook. The production proof passed on 2026-07-25 UTC (2026-07-26 Asia/Dubai) against Understudymaster@797d0e4and Metamindmaster@0814deb: a connected Chromium extension observed Example Domain, crossed the flowsafe approval boundary, filled the Expand Testing Secure Area login fromvault://metamind/portal/password, and observed the authenticated marker. The schema-v2 artifact and all 15 correlated audit entries were validated. The labeled proof batch remains indraft; no email or Gmail draft was created. - M5 — Substrate hardening. LARGELY LANDED (2026-07-17, the deferred-items sweep):
pre-accept WS/HTTP auth at the Worker edge (
onBeforeConnect/onBeforeRequest— unauthorized upgrades are 401/404 before the DO accepts, in-DO gate kept as defense in depth); credential vault hardened to AES-256-GCM envelopes over KV under aVAULT_MASTER_KEYWorker secret (src/vault.ts+scripts/vault-put.mjs; plaintext never at rest, legacy plaintext fails closed); typedDispatchOutcomeerror taxonomy across the DO RPC boundary (503 not-connected / 503 resynced / 504 timeout / 409 duplicate; no more RPC-rejection noise); idempotent write replay (see above); onClose status stamping gated on authorization; first real deploy tohttps://understudy-backend.gcharang.workers.devwith real minted secrets (runbook inapps/backend/README.md"Deploy") — live smoke: health, 401, mint, fail-fast 503, WS-gate 401, encrypted vault seed all verified. Two-tenant isolation e2e LANDED (2026-07-17): closing it surfaced a real cross-tenant vault-read gap —fillSecretresolved any caller-suppliedsecretRefwith no tenant scoping, so tenantB (driving its own session) could exfiltratevault://tenantA/…plaintext. Fixed server-side (auth.ts::tenantOf+ avault://<tenantId>/…namespace guard infillSecret, before any vault read; scrubbedok:false, no existence oracle); proven bytest/service.test.ts"two-tenant vault isolation" (session/status/WS axes were already covered). Dialog handling breadth LANDED (2026-07-17): type-aware local disposition (alert/beforeunload accept, confirm/prompt dismiss — abeforeunloaddismiss was cancelling navigations) + a best-effortdialogEvent surfaced viaGET /v1/sessions/:id(protocol 0.5.0, connector 0.3.0; residual #6). Still open under M5: session/GIF audit logging. Deferred to M6: guaranteed dialog delivery (best-effort accepted for now). - M6 — Ops. Rate/quotas at the service edge, observability, unattended-session seam scoping,
guaranteed dialog delivery (extension-side dialog buffer + replay-on-reconnect + server-side dedup —
deferred from M5's best-effort
dialogsurface; see residual #6).
Run from the Understudy repository root:
pnpm build
pnpm typecheck
pnpm testDone means all packages build and typecheck, and 279 tests pass: protocol 29, connector 29, extension 86, and backend 135. For the exact extension artifact used by an attended run:
pnpm --filter @understudy/extension typecheck
pnpm --filter @understudy/extension test
pnpm --filter @understudy/extension buildDone means 86 extension tests pass and WXT writes the production MV3 build to
apps/extension/.output/chrome-mv3.
- Unit: protocol zod round-trips (
@understudy/protocol); a11y-tree pruning/ref-assignment (cdp.tswith recorded CDP fixtures); the current a11y/keymap/CDP-event, router, ingress, dedupe, peer-binding, and WebSocket tests. - Service:
vitestwith@cloudflare/vitest-pool-workersfor the session DO (WS connect, state persistence across simulated hibernation, pending-map resume, tenant scoping,fill_secretnever logging plaintext). - Extension:
wxt build; exercise local WebSocket command flow withapps/extension/scripts/stub-server.mjs; load.output/chrome-mv3unpacked for an attended browser run. - v1 is complete. M3 satisfies the substrate criterion: a consumer drives a
multi-step task over
POST /commandswith schema-valid events, hibernation-safe settlement, and tenant/session isolation. M4's governed production proof through Metamind passed on 2026-07-25 UTC (2026-07-26 Asia/Dubai). - Quality gate before merging any non-trivial change (per project policy): parallel
quality-reviewer+architect+ independent QA subagents; fix and re-run any lane that flags.
The attended proof creates a labeled draft batch in production. Re-run it only
with explicit authorization, a connected production extension, four current
mode-0600 credential files, and the exact deployed release SHAs. From the
Metamind repository root:
node packages/worker/scripts/enrich-browser-runbook.mjs --self-test
METAMIND_URL=https://metamind.proofof.tech \
UNDERSTUDY_URL=https://understudy-backend.gcharang.workers.dev \
METAMIND_COOKIE_FILE=/secure/path/metamind-cookie \
UNDERSTUDY_CALLER_TOKEN_FILE=/secure/path/understudy-caller-token \
UNDERSTUDY_EXTENSION_TOKEN_FILE=/secure/path/understudy-extension-token \
METAMIND_ADMIN_STEP_UP_FILE=/secure/path/metamind-admin-step-up \
UNDERSTUDY_TENANT_ID=metamind \
METAMIND_RELEASE_SHA=0814deb66d55dd5ea10246c968b3d5260fbb68ad \
UNDERSTUDY_RELEASE_SHA=797d0e489df2772d0f5d597141982547861881bb \
node packages/worker/scripts/enrich-browser-runbook.mjsThe self-test prints PASS enrich-browser-runbook self-test. The attended run
must print PASS and a mode-0600 schema-v2 artifact path. The extension
operator must attach a dedicated https://example.com/ tab when prompted and
explicitly approve the displayed flowsafe request. A passing artifact records
matching release SHAs, a successful run, enriched: true, both expected evidence
markers, and correlated audit entries without any persisted secret.
- Session/WS primitives: Cloudflare Agents SDK (
agents) for the per-session DO — WS/hibernation/state. Do not hand-roll unless taking D4-alt. - Governance:
@proofoftech/breakwater(connectors, policy, RBAC, audit) +@proofoftech/flowsafe(approvals, durable runs) — in the CONSUMER, not here. Reference:smart-compliance/docs/examples/understudy-browser-connector.ts. - Element targeting pattern: mirror Playwright /
chrome-devtools-mcpARIA-snapshot+ref; themcp__claude-in-chrome__*tools are a live reference implementation. - Extension framework: WXT — don't hand-assemble MV3 plumbing.
Per-session DO + WS (service):
import { Agent, routeAgentRequest } from "agents";
export class SessionAgent extends Agent<Env, SessionState> {
onConnect(conn, ctx) { /* verify the extension's per-user token from ctx.request; else conn.close(1008) */ }
async onMessage(conn, raw) {
const ev = parseEvent(JSON.parse(raw)); // @understudy/protocol
if (ev.type.endsWith("_result") || ev.type === "pong") return this.resolvePending(ev);
/* hello → record tabs; page_event → broadcast status */
}
// POST /v1/sessions/:id/commands routes here (per-session DO); send(cmd) parks a resolver + persists the awaiting marker.
}
export default { fetch: (req, env) => routeAgentRequest(req, env) /* + Hono for /v1/... /auth /health */ };CDP click via ref (extension):
const { model } = await cdp("DOM.getBoxModel", { backendNodeId }); // ref → backendNodeId from the current snapshot map
const [x, y] = centerOf(model.content);
await cdp("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", clickCount: 1 });
await cdp("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 });Governed browser connector (CONSUMER — reference only):
// smart-compliance / metamind: a breakwater connector whose execute() calls understudy over runtime.fetch.
const act = createConnector({
id: "browser.act",
permissions: { sideEffect: "write", egress: [understudyHost], idempotencyKey: true, requiresApproval: true, dryRun: true, rateLimit: "60/min" },
execute: async (input, _ctx, runtime) => {
const res = await runtime.fetch(`${UNDERSTUDY_URL}/v1/sessions/${input.sessionId}/commands`,
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ command: toCommand(input.action) }) });
return parseEvent(await res.json()); // action_result → { ok, url, error }
},
});Notes for a fresh session changing this implementation: these anchors are as of
master@797d0e489df2772d0f5d597141982547861881bb. Reconfirm the branch, SHA,
symbols, and surrounding code before editing; do not rely on historical line
numbers. Current implementation anchors are packages/protocol/src/index.ts,
packages/connector/src/index.ts, apps/backend/src/index.ts,
apps/backend/src/session.ts, apps/extension/src/core/router.ts, and
apps/extension/src/driver/cdp.ts. The one assumption that could have forced a
design change, the chrome.debugger CDP surface, was retired at M0.