Skip to content

Webview API

The seam between the cockpit frontend (and the mini-apps inside it) and the agentctl backend. Two halves, one authoritative source each:

half authoritative source drifts when
the HTTP surface, /api/… the mux.HandleFunc block in startWebServer, internal/cli/top_web.go a route is added or renamed
the WS frame protocol FRAME_KINDS in runtime/channel/protocol.ts a frame kind is added

Request bodies field by field: read the source. How X-User-Email is established: The webview gateway § Identity and scope. What a cockpit session reaches: Security model.

The three scopes

scopeFor reads X-User-Email and resolves it through the gateway's user_email → agent ledger, returning one string. No request value anywhere in the chain is a scope claim.

scope how it arises reaches
admin header maps to an agent in the WEBVIEW_ADMINS roster every agent on the host, plus the agent-less host surfaces
owner header maps to exactly one agent that agent only
nobody header maps to no ledger entry, or there is no header at all (scope "") nothing — deny-all

There used to be a fourth, operator: no X-User-Email at all, reaching everything unfiltered. It served a non-gateway loopback top --web that no longer exists, and on a shared host it was reachable by any co-resident uid that could open a socket to the cockpit port. A missing header is now the deny-all scope, indistinguishable in authority from an unknown address.

The gate column in the tables below names which predicate a route uses.

predicate passes for
allowAgent(name) admin, any name; owner, own agent only; the empty scope, never — including the empty name
fleetView admin only — it is now exactly adminScope; used where a route is host-wide rather than per-agent
adminScope admin only; decides snapshot filtering as well as access

Session, transcript and turns

agent is a query parameter on the GETs and a body field on the POSTs. session selects a subsession lane; empty means the primary lane. host is vestigial on every route that takes it: the SSH transport it selected is gone, along with the remoteTarget/mutateTarget seams that read it. There are exactly two live transports now — op-relay to a dialed-in agent, or a local read — and the choice is made by asking the dial-in registry, never by resolving a host.

route method request response gate
/api/snapshot GET — fleet snapshot JSON scopeFor + adminScope; an owner's copy is filtered by scopeSnapshot to their own card, an admin's is verbatim
/events GET — SSE, snapshot deltas scopeFor + adminScope, same filtering
/watch GET agent SSE, one JSON fleet.Event per frame, 12-event seed allowAgent
/api/transcript GET agent, session the same SSE feed, 200-event seed allowAgent
/api/history GET agent, before, limit JSON page of older events allowAgent; before must match the cursor grammar <digits>[:<digits>]
/api/pending GET agent, session JSON []fleet.PendingTurn — sent but not yet in the transcript allowAgent
/api/sessions GET agent JSON session registry, main + subs allowAgent
/api/subagents GET agent, session JSON running/finished subagents allowAgent
/api/session/list GET agent, since, limit JSON sessions active in a window allowAgent
/api/session/timeline GET agent, session JSON tool-call waterfall allowAgent
/api/fsevents GET agent, session SSE, one fs_event frame per line allowAgent; gateway-mode only, 501 otherwise
/api/inject POST {agent, host, text, session, uploads[], message_id} {ok, …} allowAgent, resolved before the agent name is used to read any path
/api/permission POST {agent, session, request_id, behavior} where behavior is allow|deny {ok, …} allowAgent; gateway-mode only
/api/agentmsg/engage POST {agent, …} {ok, …} allowAgent — an ownership check on the receiving agent; it grants the sender nothing

/api/inject takes JSON only. Attachments are named by tus upload id, never inlined.

Subsession control

Five routes over one handler (subsessionControl), all POST {agent, title, new_title?}, all allowAgent, all gateway-mode only. Each op-relays subsession <verb> … self to the agent, which routes it to its own local agentd.

route effect
/api/subsession/start start a subsession
/api/subsession/resume resume it
/api/subsession/clear stop, keep transcript
/api/subsession/rm deregister, keep transcript
/api/subsession/rename label only — sid, unit and transcript untouched

Workspace

Read routes run locally or over the agent's dial-in; mutating routes are op-relayed to the agent, which executes them as itself. All gate on allowAgent over the agent parameter.

route method request response notes
/api/ls GET agent, path JSON directory listing the file tree's seed; /api/fsevents is the follow
/api/file GET agent, path, hash=1 text/plain, or JSON when hash=1
/api/raw GET/HEAD agent, path, dl file bytes, content-type by extension serves Range requests; caches byte length only, never content
/api/render GET agent, path text/html server-side render: Markdown, CSV/JSONL as a table, and Chroma syntax highlighting for JSON and a fixed set of code extensions (renderFile, ws_render.go); anything else falls back to an escaped <pre>
/api/inbox GET/HEAD agent, file | fileid inbound-attachment bytes chat thumbnails
/api/write POST agent, path, base {…} JSON base is the optimistic-concurrency precondition
/api/mkdir POST agent, path {…} JSON
/api/rm POST agent, path, recursive=1 {…} JSON
/api/mv POST agent, src, dst {…} JSON

Uploads — /api/tus/

The only upload path. tus 2.x resumable protocol.

method effect
POST create an upload
HEAD report offset
PATCH append
DELETE discard
OPTIONS report capabilities
detail behaviour
tusGate runs outside tusd's own middleware; no request reaches the store before scope is checked
X-HTTP-Method-Override refused, not emulated
unresolvable upload id 404, not 403 — the endpoint is not an oracle for which ids exist

Office viewer

route method effect
/api/office/open POST stage a workspace docx/xlsx/pptx into OneDrive
/api/office/pdf GET PDF bytes for the <embed>
/api/office/preview GET Office-for-web embed URL
/api/office/pullback POST reconcile edited bytes back to the local file, on blur/close/beacon

All four take agent + path and gate on allowAgent. The cockpit tracks which files a live session has open, so the main-SSE close reconciles them if the browser disappears.

Mini-apps and the data plane

route method request response gate
/api/apps GET agent JSON active apps (installed × wired) allowAgent, then keepAdminApps(…, fleetView)
/api/apps/<id>/bundle.js GET agent the app's component JS allowAgent
/api/mcp/wired GET agent JSON wired-MCP set allowAgent
/api/mcp/read POST {agent, mcp, tool, args} raw tool JSON allowAgent then mcpActionToolAllowed
/api/mcp/call POST {agent, mcp, tool, args, timeout} raw tool JSON allowAgent then mcpActionToolAllowed

A manifest's admin: true only filters the launcher: keepAdminApps hides an icon. The endpoints an admin app reads are gated independently.

The two endpoints are one plane. /api/mcp/read used to apply a read-only tool-name predicate (isReadOnlyMCPInvocation) and /api/mcp/call a manifest actions: true opt-in; both are gone. Each now runs mcpActionToolAllowed in the handler and again in the verb the op-relay carries — and the two verbs (mcp read, mcp call) are one implementation. The differences left are the optional timeout on /call and which ctx method an app author writes.

mcpActionToolAllowed, in order:

gate rule
risk catalog a tool declared risk: block in its mcp.yaml is refused. Nothing in the shipped catalog declares one today, so this tier is currently a no-op
turnless-only exclusions browser.evaluate_script and browser.upload_file, whatever their tier
per-tool argument narrowing a narrowed tool may carry only its listed argument keys — take_screenshot without filePath, ADO's PR write without bypassReason

Everything else — every MCP, every tool, risk: ask and undeclared alike — is reachable from both endpoints, mutating or not.

Both planes are turnless: they op-relay to the connected agent, which invokes the tool over its own MCP socket, with no agent turn and no root daemon hop. A tool declared risk: ask is reachable here with no confirmation step — see Security model. Both return 502 with {"ok":false,"msg":"agent not connected"} when the agent has no live dial-in.

Transport tunnels

/browser/ and /terminal/ are proxies, not JSON APIs. Both take the same two-step entry.

request behaviour
GET /<mount>/enter?agent=<name> allowAgent, resolve the transport, then mint a session token and Set-Cookie
— no reachable transport 404, returned before any cookie is minted
GET /<mount>/exit drop the session, clear the cookie, 204
GET /<mount>/<anything else> proxied to the agent's transport, prefix stripped
— no cookie 403, not 404 — a prober learns nothing about the agent, port or display

The browser cookie is HttpOnly and SameSite=Strict. Secure is set only when the request arrived over TLS. What sits behind these two routes, and the root-owned admin marker /terminal/ additionally requires, is in Security model.

Host and fleet routes

/api/agents/restart-self (POST) derives its target from scopeFor; a request that resolves to no agent is refused, and a client-sent name is ignored with the resolved one echoed back.

/api/agents/restart (POST {agent, host}) restarts one named agent and gates on fleetView before the body is read. validAgentName rejects the selector keywords all, self and shared, which the local branch would otherwise hand to agentctl agents restart <target>.

The remaining host routes are the add wizard and the catalog authoring surface. Each shells an agentctl verb through runVerb, which execs the local binary against the on-box daemon. Every one of them gates on fleetView — admin role, or a local operator cockpit — before the body is read or, for the /api/host/<host>/… group, before the path is even split.

route method response gate
/api/agents/add POST NDJSON progress (phase/step/log/progress/done/error) fleetView
/api/apply-all POST NDJSON, {"t":"hosts"} first, per-host events tagged host, {"t":"all-done"} last fleetView
/api/host/<host>/capabilities GET JSON fleetView
/api/host/<host>/catalog GET JSON catalog browse fleetView
/api/host/<host>/catalog/add POST {ok, msg} fleetView
/api/host/<host>/catalog/remove POST {ok, msg} fleetView
/api/host/<host>/defaults GET JSON fleetView
/api/host/<host>/defaults/set POST JSON fleetView
/api/host/<host>/gateway/install POST NDJSON fleetView
/api/host/<host>/slack-gateway/install POST NDJSON fleetView

The /api/host/<host>/… group shares one dispatcher, handleHostAPI in internal/cli/top_catalog.go, and the gate sits there rather than in each of the eight handlers: a tail added later is gated by construction, and a test derives its expected route list from the dispatcher's own source so the two cannot drift. /api/agents/add (top_add.go) and /api/apply-all (top_apply_all.go) carry the same check at the top of their own handlers, for the same reason /api/agents/restart does. The cockpit hiding the buttons that reach these routes (they ship hidden, clicked only by the fleet mini-app, admitted by the same fleetView predicate) is not the enforcement — the server-side check is.

/api/def (GET agent, kind, name, host, path=1|json=1|catalog=1) gates on allowAgent first. For the two agent-less lookups that ignore name and read host-global files — catalog=1 and kind=hostcron — it re-checks scopeFor + adminScope. A scoped owner is refused despite clearing the per-agent gate; a stranger is denied twice.

Diagnostics

route method request response gate
/logs GET agent, host SSE, one frame per journal line allowAgent
/api/pii-log GET agent, host, session JSON array of PII redaction records allowAgent

/logs follows the agent's systemd unit (handleLogs, top_web.go) — exactly two branches: journalctl -f op-relayed to the agent's own host when it is dialed in, otherwise delegated locally to unit logs (which routes through agentd for a non-root cockpit, so it reads the unit journal and not the caller's own). The stream ends when the browser closes the drawer.

/api/pii-log is a disclosure endpoint (the code's own comment: "★ THIS IS A DISCLOSURE ENDPOINT" — pii_log_web.go). Each record pairs a real PII value with the placeholder the pi PII filter substituted for it, read from a 0600 side-log the filter extension writes as it redacts. This is not a new exposure — the same values already sit in the session transcript this cockpit renders in full — but it is a second copy of them, purpose-built for the chat window's "tape-over" reveal toggle, so it holds the same allowAgent line as the rest of the surface: a scoped viewer reaches only their own agent's log, capped at piiLogCap records, and the response is Cache-Control: no-store. There is no write half — the verb underneath is self-scoped and read-only, and which agent's log is read is decided by the connector, never by a parameter a caller can point elsewhere.

PWA and static routes

/, /manifest.webmanifest, /sw.js, /icons/ and /fonts/.

surface behaviour
index resolves scope and injects window.__WV_SCOPE__ = {agent, admin} to paint with; grants nothing
authenticated principal with no ledger entry 403 page — no cockpit shell, no scope injection, no version string, no boot gate
that 403 page echoes the identity, HTML-escaped
/manifest.webmanifest per-agent — name and start URL follow the scoped agent
login proxy passes these four prefixes through unauthenticated, deleting any X-User-Email: /manifest.webmanifest, /sw.js, /icons/, /fonts/. / itself is not on this list — it goes through ensureAuth like everything else

Every handler re-derives scope per request. See The webview gateway § Cockpit login.

WS frames

Two frame families share these sockets, discriminated on different keys. They have separate vocabularies, separate validation and separate demultiplexers, so they are counted separately below.

The t family — wvFrame

FRAME_KINDS in runtime/channel/protocol.ts is the admission set for this family: ws.ts consults isFrame() on the live inbound path and drops anything not in it. Frames are newline-delimited JSON, one per line. isFrame validates that t is a known kind, and nothing deeper.

The set holds 33 names, and seven of them are dead — the durable host-operation family: host_op_sync, host_op_dispatch, host_op_cancel, host_op_receipt, host_op_output, host_op_terminal, host_op_advertise. No GATEWAY handler routes any of them (Phase 5a retired the coordinator), though the CONNECTORS still serve three of them through shared/host-op-durable.ts. They stay in FRAME_KINDS.

host_op_request and host_op_result were two more and Phase 5d removed them, taking the set from 35 to 33. Every producer and consumer of the pair is gone — the source CLI and gateway relay in 5c, and HostOpRelay, relayHostOp, both connector arms, the host-op-exec verb and daemon.handleHostOp in 5d — so the frame interfaces, their union members and their FRAME_KINDS entries went with them rather than being left declared with nothing on either end.

That is NOT a behaviour change for a mixed-version fleet, and the reason the earlier text gave for keeping them was wrong. It said "admitted and ignored instead of having its socket closed"; ws.ts reads if (!isFrame(p)) return; — an unadmitted frame is dropped, the socket is untouched. With the name in the set it reached a switch whose default is a bare break; without it, ws.ts drops it one layer earlier. Identical outcome.

The GO GATEWAY still drops the pair BY NAME, and must keep doing so. That is a different question with a different answer, and the distinction is security-relevant: the bottom of the gateway's read loop is fanout, which pushes to a lane's browser subscribers, and a host_op_request carries a signed short-lived capability while a host_op_result carries relayed command output. Neither belongs in a cockpit lane, and an OLD connector during a rolling upgrade is exactly where one arrives. See internal/cli/webview_dialin.go.

That leaves 26 live t kinds:

inbound, inbound_ack, outbound, permission_request, permission_reply, attachment_fetch, attachment_result, register, hello, reject, ping, pong, op, op_result, op_cancel, fs_event, browser_tunnel, terminal_tunnel, host_shell_tunnel, oauth_callback, oauth_callback_result, agent_message, agent_roster, agent_message_result, agent_message_deliver, agent_message_ack.

Two exceptions inside that 26:

  • reject is admitted but has no occurrence in the Go gateway at all. It is the Teams gateway's refusal frame; this gateway neither emits nor handles it.
  • userecho runs the other way. The gateway emits it (top_web.go) but it is not in FRAME_KINDS, so isFrame() drops it before any browser sees it — it is dead.

The type family — host jobs and agent administration

host_job_request|dispatch|ack|event and agent_admin_request|dispatch|ack|event|body_chunk — nine envelopes — are not t kinds. They are internal/proto's own structs carried on the wire as {"type":"…"}, and the host dial-in read loop demultiplexes them on type ahead of the wvFrame unmarshal: agent_admin_* by prefix first, then the host-job router. They are absent from FRAME_KINDS and never pass through isFrame(). wvFrame has no type key, so every t frame decodes with an empty Type and falls through to the original path untouched.

The host dial-in socket additionally carries host_status (host → gateway) and host_status_request (gateway → host) on t; they are not in FRAME_KINDS, which governs the agent connector's inbound path rather than the host connector's.

An unrecognised kind in either family is ignored rather than refused, so an older target still speaking a retired frame is an ordinary rolling upgrade and not an error.

group kinds carries
op-relay op, op_result, op_cancel every workspace, MCP and subsession route above; results route back by sequence id
— chunks base64-encoded, so binary ws raw and ws render output survives the JSON frame
host jobs host_job_request, host_job_dispatch, host_job_ack, host_job_event a verb run as root on another host; survives a gateway restart and a target reconnect
agent administration agent_admin_request, agent_admin_dispatch, agent_admin_ack, agent_admin_event, agent_admin_body_chunk a verb run as one agent's own uid on its own host; the body chunk carries ws write and inject payloads
tunnels browser_tunnel, terminal_tunnel, host_shell_tunnel the transports /browser/ and /terminal/ redeem
agent-to-agent agent_message*, agent_roster agent_message_deliver paints a card; only /api/agentmsg/engage makes it a turn
delivery receipt inbound_ack one per inbound carrying a meta.message_id; correlates on that id. The gateway waits for it before /api/inject answers {"ok":true}, but ONLY from a peer whose register carried acks: true — a socket write is not a delivery, and a peer too old to answer must not have its sends reported as failures
liveness ping, pong the agent's own application-level probe. The gateway's heartbeat() ping is a WebSocket control frame the peer's TCP stack answers without its application seeing it, so a half-open path is visible to the gateway and not to the agent; this pair is the probe in the other direction

For what the cross-host kinds carry, see Architecture § Reaching another host.

register carries the authoritative routing key and is the frame the gateway authorises a dial-in on — see The webview gateway § Dial-in: how an agent registers. For lanes, see Channels § Lanes.