MCP client (Claude/Codex…) ─ stdio ─→ sctl mcp ─┐ (authenticated control API)
CLI verbs (sctl get / edit / install / browsers / tabs / windows / page / debug …) ┤
▼
sctl serve (daemon; defaults to 127.0.0.1:8643)
▲ WebSocket (each extension dials in + mutual HMAC handshake)
ScriptCat browser extension (authority for write approval and source disclosure)
sctl Browser extension, one or more paired instances (browser control; approval window for L2; CDP relay)
sctl mcp and the CLI verbs are separate processes from sctl serve. They talk over the
/control/* HTTP/JSON control API on the daemon's listener — same port as the extensions' WS surface, separate
path, authenticated with the control token described in threat-model.md.
Frontends never start serve: run it
explicitly in the foreground or let an external system service manager own its lifecycle.
The daemon accepts two kinds of extension connection at once: exactly one active ScriptCat connection (a new
one replaces the previous one), and any number of paired sctl Browser instances connecting simultaneously,
each identified by its own instance ID. A call is routed by which peer kind owns its method (scripts.* to
ScriptCat, tabs.*/windows.* to a resolved browser instance) — see
protocol.md. For ScriptCat's methods, the daemon approves no
write on its own — it forwards the request and blocks until a human decides in the browser, and source reads
go through the same disclosure gate. Browser control methods carry no such human gate by design: any
control-token holder can drive a paired browser instance immediately. Details in
threat-model.md.
Every method carries a destruction level (L0 / L1 / L2, see protocol.md). The
L1 confirmation is checked twice: controlapi rejects an L1 call whose input lacks confirm: true with
CONFIRMATION_REQUIRED before bridge forwards anything, and the sctl Browser extension's handler registry
(extension/src/background/registry.ts) checks again before running the handler. The daemon never decides L2 —
it forwards the call and blocks, as for ScriptCat's gates. The L2 gate lives entirely in the sctl Browser extension:
its service worker validates the request, queues it in chrome.storage.session (so it survives the worker being
suspended), badges the toolbar icon, and opens its own approval window (extension/src/approval/), a separate popup
window of the extension. The offscreen document holds back the JSON-RPC answer until the window's decision is carried
out, tells the daemon with $/approvalPending once a request has entered the queue, and forwards the daemon's
cancellations and its own disconnects to the queue (protocol.md).
bridge hands that notification to the waiting call through Request.OnPending; when the requester set
reportPending on /control/call, controlapi writes an interim {"pending":true} line ahead of the result, which
is when the CLI prints its waiting line and sctl mcp starts its progress notifications.
sctl page, sctl debug, and sctl cdp send commands and the page_*, debug_*, and cdp_send MCP tools reach the daemon through /control/page, not /control/call: a
page action is not one extension method but a sequence of Chrome DevTools Protocol (CDP) commands decided in Go.
The page automation component (internal/daemon/page) resolves the browser and tab, then drives the tab through
the extension's internal relay methods (protocol.md):
sctl page / page_* ─/control/page─▶ controlapi ─▶ page.Manager ─▶ bridge ─WS─▶ sctl Browser ─chrome.debugger─▶ tab
▲ │
└── debugger.detached ◀──────┘ (BrowserListener)
The extension only relays CDP commands, events, and detach notices; the page logic lives in Go so it can be
tested against a fake CDP. Page state — which tabs are attached, their idle
timers, and the per-tab queue that runs commands on one tab in arrival order — lives in the daemon's memory,
because the daemon is the only process that outlives a single command. When the daemon attaches and detaches a
tab is described in protocol.md. Each attach hook gets 5 seconds to be answered,
after which the attach is given up with PAGE_UNRESPONSIVE; and before the daemon or the extension detaches a tab
itself, it dismisses a JS dialog it knows is open there, because a dialog left behind by a detached debugger blocks
every later attach.
The same component keeps the debug records of each attached tab (sctl debug, debug_*). Every attach creates a
fresh per-tab state (page.Tab) that holds two ring buffers, at most 1000 console records and 1000 network requests,
each also capped at 32 MiB of record content (a record is page-controlled and can approach one 4 MiB frame) with the
oldest dropped first; attach hooks enable the Runtime, Log, and Network domains on the tab's top-level session (Network with a small
maxPostDataSize, so a large request body cannot push an event over the frame limit), and the CDP events the
extension relays are converted into records in the bridge read loop. Request and response bodies are not buffered:
debug request reads them on demand through debugger.body, which cuts them to 1 MiB in the extension. Cross-process
iframes are attached automatically with waitForDebuggerOnStart, so a new one starts paused: a background task of the
tab enables the same domains on its child session and then always resumes it, because event handlers run in the bridge
read loop and must not send commands. Since the buffers belong to the per-tab state, every path that detaches the
debugger — idle detach, page detach, a debugger.detached notice, the browser instance going away, a failed attach
— drops them, and before the daemon itself detaches a tab it ends and waits for that tab's background tasks so none of
them re-attaches it. Debug records never leave the daemon's memory.
Recording (debug start/stop/status) is also per-tab state. A recording tab gets no idle-detach timer after its
commands; instead a 60-minute timer, restarted by every debug command on the tab, ends the recording through the tab's
queue and re-arms the idle timer, with a sequence number discarding a timer that fired late. The daemon tells the
extension through debugger.record so its own idle fallback leaves the tab alone too. Because recording lives in the
per-tab state, every detach path ends it.
A raw CDP endpoint client and sctl never drive the same browser's tabs at once, because all users of a tab share its
one debugger session. page.Manager.HandOver hands one browser instance's tabs over to an endpoint client: it
cancels sctl's commands running on that instance with ENDPOINT_CONNECTED and waits for them and for any idle detach
or recording expiry in progress, then detaches as page detach --all does — ending recording, dismissing known
dialogs first, and dropping the debug records. From then on every /control/page action on that instance (page,
debug, cdp send) fails with ENDPOINT_CONNECTED before anything is sent to the browser, until
page.Manager.Reclaim gives the tabs back. Only /control/page goes through the page component, so /control/call
methods (tabs, windows, bookmarks and the other browser control verbs) keep working on that browser by
construction.
The raw CDP endpoint component (internal/daemon/cdpendpoint) gives a Playwright or Puppeteer client one browser's
tabs. component.go mounts its Manager on the daemon mux at /cdp/ and registers it as a second
BrowserListener with bridge.Server.AddBrowserListener, so it receives the same notifications as the page
component plus OnInstanceForgotten; sctl cdp endpoint|status|close and the cdp_endpoint and cdp_close MCP
tools reach it through /control/cdp/*. Endpoints, their secrets, and their clients live only in the daemon's memory.
URL forms, checks, and lifecycle are in protocol.md.
Playwright / Puppeteer ─WS /cdp/<secret>/…─▶ cdpendpoint.Manager ─▶ Hook ─▶ Browser ─▶ bridge ─WS─▶ sctl Browser ─▶ tab
sctl cdp endpoint|status|close ─/control/cdp/*─▶ controlapi ─┘ │
page.Manager.HandOver / Reclaim ◀┘
Ownership switches at the client's edges. Before upgrading a client's WebSocket, the endpoint calls
page.Manager.HandOver for the instance and refuses the client if it fails; after the client is gone — whatever
ended it — it dismisses known dialogs, detaches the tabs the client attached, clears their endpoint-owned marks, and
only then calls page.Manager.Reclaim. Every path that ends a client goes through that one cleanup, because the
handed-over state survives the instance reconnecting. Context cancellation drives every daemon-side end: sctl cdp close, expiry, and sctl browsers forget cancel the endpoint's context, the instance disconnecting cancels the
client's, and the daemon's own context is the parent of both — needed because bridge shuts down only its own
connections and a hijacked client WebSocket lives outside http.Server.
The endpoint keeps the transport and the ownership; what the client's CDP is answered with is a narrow Hook
(Serve(ctx, conn, browser)), given the accepted connection and a per-client cdpendpoint.Browser that wraps the
instance's internal methods (debugger.targets, userAgent, open, close, own, send, detach) plus
tabs.activate, and delivers its notifications. Browser records which tabs the client attached, marked, and has an open dialog on, which is what the
cleanup works from. cdpendpoint reaches the page component only through its own Pages interface, without
importing page.
internal/ is grouped by process role: daemon/ is the guard side (sctl serve), client/ is the
request side (sctl mcp and the CLI verbs), cli/ wires commands to those roles, and pkg/ is shared.
The layering convention is borrowed from cago, with internal/pkg/ as
the shared layer and store playing the repository role.
cmd/sctl/main.go # cobra entry point (unwraps ExitError → os.Exit)
internal/cli/ # subcommand definitions; spans both sides, hence top level
cli.go # root command, global flags (-o/--output, --log-level), output helpers
serve.go # bootstraps the cago app and mounts the daemon Component
mcp.go # sctl mcp (serves all tools; --name is an audit label)
connect.go status.go version.go
get.go grep.go # read verbs
edit.go write.go # write verbs (edit / install / enable / disable / delete)
browsers.go # sctl browsers [list] / sctl browsers forget <name|id>
tabs.go windows.go # sctl tabs list|open|close|activate, sctl windows list
page*.go # sctl page snapshot|click|hover|fill|type|press|select|upload|scroll|goto|back|forward|
# reload|wait|screenshot|eval|dialog|detach (reach /control/page through dispatchPage)
debug.go # sctl debug start|stop|status|console|network|request|clear (also through dispatchPage)
cdp.go # sctl cdp send (also through dispatchPage), sctl cdp endpoint|status|close (/control/cdp/*)
resource.go # the optional scripts|script|sc resource word shared by those verbs
dispatch.go # action forwarding and bridge error → exit code mapping
internal/daemon/ # ── sctl serve side ──
component.go # cago Component: assembles listener + bridge + page + cdpendpoint + controlapi
bridge/ # WS service core
server.go # Server struct, Serve, Origin whitelist, handshake admission, connection registry
conn.go # single connection: handshake, read loop, send
call.go # action forwarding, pending-call table, JSON-RPC cancellation
pairing.go # extension enrollment window (out-of-band code → key K)
envelope.go # envelope, payload structs, error codes
controlapi/ # /control/* handlers (controller role), depends on the narrow Bridge and Page interfaces
page/ # page automation: Manager (target tab, per-tab queue, attach and idle detach),
# page actions, per-tab debug record buffers, the bridge-backed CDP implementation
cdpendpoint/ # raw CDP endpoint: per-browser endpoints, /cdp/ HTTP and WS, Host/Origin/secret checks,
# expiry, hand-over to and from page, the per-client Hook and Browser
auth/ # mutual HMAC handshake, enrollment-code derivation (HKDF), key delivery (AES-GCM)
store/ # persistence (repository role): ScriptCat's long-term key K, plus the
# browsers.json registry of paired sctl Browser instances and their own
# per-instance keys
ratelimit/ # enrollment-attempt rate limiting
internal/client/ # ── sctl mcp / CLI verb side ──
control/ # control API client, shared DTOs, control token
mcpserver/ # go-sdk stdio MCP server: all tools (flat trust), progress while waiting
internal/pkg/ # ── shared by both sides ──
protocol/ # protocol.json authority plus generated language and business-schema artifacts
protocolschema/ # JSON-RPC parsing and business-schema validation at the untrusted WS boundary
audit/ # daemon-side security events (the Event type crosses the control API, hence shared)
paths/ # data directory and derived paths
logging/ # unified zap logging (stderr + <dataDir>/logs/*.log, never stdout)
fsutil/ # atomic file writes
extension/ # ── sctl Browser, the second extension kind (MV3, pnpm/Vite/React) ──
src/background/ # service worker: identity and settings storage, message routing, method dispatch
src/offscreen/ # holds the WebSocket and connection state, pairing and session handshake, retry/backoff
src/handlers/ # browser method implementations, one module per domain (chrome.tabs, chrome.bookmarks,
# chrome.debugger, …)
src/approval/ # L2 approval window UI (bookmark deletion, extension uninstall)
src/popup/ # popup UI: pairing, rename, forget, daemon address
src/protocol/generated/ # browser-only generated protocol TS (see protocol.md §6)
cli → daemon (for serve only) and client. daemon/controlapi → daemon/page → daemon/bridge and
daemon/controlapi → daemon/cdpendpoint → daemon/bridge, never the reverse: the control API sees the guard only
through the narrow controlapi.Bridge, controlapi.Page, and controlapi.CDPEndpoints interfaces, page reaches
browsers only through its narrow page.CDP interface, cdpendpoint reaches browsers through cdpendpoint.Bridge and
the page component through cdpendpoint.Pages without importing it, and bridge knows nothing about HTTP paths, page
automation, or the endpoint — component.go registers the page Manager as the bridge's BrowserListener and the
endpoint Manager as an additional one. The /control/* routes are registered by controlapi.Handler.Register, on the mux
internal/daemon/component.go assembles and hands to bridge.Server.Serve (which owns only /); component.go also
mounts the endpoint on /cdp/. The shared
DTOs live in client/control and are referenced one-way by controlapi.
Two further cross-package conventions: sensitive files reach disk only through internal/pkg/fsutil, and
stdout belongs to internal/cli alone — the latter because stdout is claimed exclusively by sctl mcp's
JSON-RPC channel. Both are held by review today.