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:
rejectis 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.userechoruns the other way. The gateway emits it (top_web.go) but it is not inFRAME_KINDS, soisFrame()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.