Skip to content

Webview API

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

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

Read the source for the request bodies field by field; this page gives the shape, the method, and the scope each route requires. Who the caller is — how X-User-Email is established and why a missing header is more privileged than a present one — is The webview gateway. What an attacker holding a cockpit session reaches is Security model.

The four scopes

Every scope decision in the cockpit derives from scopeFor, which reads X-User-Email and resolves it through the gateway's user_email → agent ledger. There is no request value anywhere in the chain, so there is nothing for a caller to claim. Four outcomes:

scope how it arises reaches
operator no X-User-Email at all (scoped=false) everything, unfiltered
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
stranger header authenticates but maps to no ledger entry (scope "") nothing — deny-all

Three predicates read those facts, and the column in the tables below names which one a route uses:

  • allowAgent(name) — the per-agent gate. Operator passes; stranger is denied for every name including the empty one; owner passes only for their own agent; admin passes for any.
  • fleetView — operator or admin. Used where a route is host-wide rather than per-agent.
  • adminScope — admin only, never the operator. Used to decide filtering, not access.

The stranger case is the one with history: allowAgent's earlier form compared name != scope, so a request with agent="" satisfied "" != "" as false and passed, reaching the agent-less def handlers and from there a traversal read of root-owned files. top_web_scope_test.go pins that closed and pins that adminScope can never answer true for the empty scope.

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 — remoteTarget always returns "" since the multi-host collector retired.

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 — multipart was removed because it cannot be chunked, resumed, retried or measured.

Subsession control is five routes over one handler (subsessionControl), all POST {agent, title, new_title?}, all allowAgent, all gateway-mode only. They op-relay subsession <verb> … self to the agent, which routes it to its own local agentd:

/api/subsession/start · resume · clear (stop, keep transcript) · rm (deregister, keep transcript) · rename (label only — sid, unit and transcript untouched).

Workspace

The read routes run locally or over the agent's dial-in; the mutating ones are op-relayed to the agent, which executes them as itself. All are gated by allowAgent on the agent parameter, which is what binds a path to its owner.

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 Markdown render
/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: POST creates, HEAD reports offset, PATCH appends, DELETE discards, OPTIONS reports capabilities. tusGate runs outside tusd's own middleware, so no request reaches the store — not even to learn whether an id exists — before scope is checked. Two details are load-bearing:

  • X-HTTP-Method-Override is refused, not emulated. tusd honours it on a POST and rewrites r.Method after the gate has run, so a POST to someone else's upload id carrying Override: PATCH would be authorised as a create and executed as a patch.
  • An unresolvable id returns 404, not 403, so the endpoint is not an oracle for which upload ids exist.

Office viewer. /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 — fired by the client on blur/close/beacon). All take agent + path and gate on allowAgent. The cockpit tracks which files a live session has open so that the main-SSE close can reconcile 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 read-only predicate then allowAgent
/api/mcp/call POST {agent, mcp, tool, args, timeout} raw tool JSON allowAgent then the risk catalog + per-tool arg narrowing

A manifest's admin: true only filters the launcher — keepAdminApps hides an icon. It is not a boundary; the endpoints an admin app reads are gated independently.

/api/mcp/read is the read-only plane. isReadOnlyMCPInvocation is checked in this handler and again in the mcp read verb the op-relay carries. Note the ordering: the read-only check runs before allowAgent, so a non-read-only tool name is refused with 403 regardless of who asked — the caller learns nothing about the agent from the refusal.

/api/mcp/call is the action plane, and it is narrower than "not read-only". Two gates, both mcpActionToolAllowed, both re-checked verb-side:

  1. the risk catalog — an allowlist of MCPs. Everything outside it is refused outright and keeps mcp read as its only turnless path, so nothing here is reachable for gmail, drive or ms365.
  2. per-tool argument narrowing — an MCP may be reachable with only some of its tools and only some of their argument keys. video is unnarrowed; browser is one tool with one argument.

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. allowAgent is what makes a turnless action an action the owner took. The consequence worth naming is in Security model: a tool declared risk: ask is reachable here with no confirmation step, because the confirmation lives in the agent's turn and this plane has none.

Both return 502 with {"ok":false,"msg":"agent not connected"} when the agent has no live dial-in.

Transport tunnels

/browser/ and /terminal/ are not JSON APIs; they are proxies with a two-step entry. Both follow the same shape:

  • GET /<mount>/enter?agent=<name>allowAgent, then resolve the agent's transport, then mint a session token and Set-Cookie. Resolution happens before the cookie is minted, so an agent with no reachable transport returns 404 rather than a cookie that fails later.
  • GET /<mount>/exit → drop the session, clear the cookie, 204.
  • GET /<mount>/<anything else> → proxied to the agent's transport, prefix stripped. No cookie means 403, not 404 — a prober learns nothing about whether the agent, the port or the display exists.

The browser cookie is HttpOnly and SameSite=Strict. Strict is measured, not tidy: KasmVNC 1.4.0 requires an Origin header on the websockify upgrade but does not validate it, so a foreign origin gets a 101 all the same and the cookie is the only thing between a hostile page and a websocket onto that browser. Secure is set only when the request arrived over TLS, or a plain loopback cockpit would drop the cookie entirely.

What sits behind these two routes, and why /terminal/ additionally requires a root-owned admin marker that systemd itself asserts, is in Security model.

Host and fleet routes

/api/agents/restart-self (POST) is self-scope by construction: the target is derived from scopeFor, and a request that is not scoped to an agent is refused. A client that sends a different name gets the resolved one echoed back.

/api/agents/restart (POST {agent, host}) restarts one named agent and gates on fleetView. The check runs before the body is read, so nothing in the request can influence it and an unauthorised caller learns nothing about which agents exist. validAgentName rejects the selector keywords all, self and shared — load-bearing, because the local branch hands the string to agentctl agents restart <target> where all is a real argument.

The remaining host routes are the add wizard and the catalog authoring surface. They do not write files themselves; each shells an agentctl verb through runVerb, which in the single-host mode that ships today execs the local binary against the on-box daemon.

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

These routes carry no scope check of their own

Every route in the table above enforces its HTTP method and validates its body, and then executes. None of them calls allowAgent, fleetView or scopeFor, so unlike /api/agents/restart directly above them they do not distinguish an operator or an admin from an owner or from an authenticated stranger. The cockpit only offers them to a fleetView caller, but that is a launcher decision in the browser, not a server-side gate.

The reasoning they were written under is recorded in top_defaults.go: the web layer never writes files itself, so the daemon "performs the privileged write+commit and its peercred gate keeps overlay editing operator-scoped". That gate is real, but it reads the uid of the process running the verb, not the identity of the HTTP caller. The verb here is run by the cockpit, and agentctl-webview-gateway.service carries no User= — so the uid agentd sees is root, which is operator scope by definition. The daemon check therefore cannot separate one cockpit caller from another; it only confirms that the cockpit is the cockpit.

Treat the reachability of the cockpit port as the effective control on this group, and read The webview gateway § Is :8731 genuinely loopback-bound? for who that leaves.

/api/def (GET agent, kind, name, host, path=1|json=1|catalog=1) is the exception in this group and shows the intended shape: it gates on allowAgent first, and then, for the two agent-less lookups that ignore name and read host-global files — catalog=1 and kind=hostcron — re-checks scopeFor + adminScope so a scoped owner is refused even though they cleared the per-agent gate. A stranger is denied twice.

PWA and static routes

/, /manifest.webmanifest, /sw.js, /icons/ and /fonts/. The index resolves scope and injects window.__WV_SCOPE__ = {agent, admin} for the shell to paint with; it grants nothing, because every handler re-derives the same facts per request. An authenticated principal the ledger has no entry for gets a real 403 that never touches the cockpit shell — no scope injection, no version string, no boot gate — because the earlier behaviour served a live-looking cockpit whose every data call then 403'd, which reads as "broken" rather than "not authorised". The identity is echoed into that 403 page and is HTML-escaped.

The manifest is per-agent (name and start URL follow the scoped agent) so an installed PWA is that agent's. The login proxy lets these five prefixes through unauthenticated and deletes any X-User-Email on that path — see The webview gateway.

WS frames

FRAME_KINDS in runtime/channel/protocol.ts is authoritative, and it is not a typechecker convenience: ws.ts consults isFrame() on the live inbound path and drops anything not in the set. A frame declared only in a handler's switch never reaches that switch. The 31 kinds:

inbound, outbound, permission_request, permission_reply, attachment_fetch, attachment_result, register, hello, reject, op, op_result, op_cancel, host_op_request, host_op_result, host_op_dispatch, host_op_cancel, host_op_sync, host_op_receipt, host_op_output, host_op_terminal, host_op_advertise, fs_event, browser_tunnel, terminal_tunnel, host_shell_tunnel, oauth_callback, oauth_callback_result, agent_message, agent_roster, agent_message_result, agent_message_deliver.

Frames are newline-delimited JSON, one per line. isFrame is shallow — it validates that t is a known kind and nothing deeper, because the socket is same-host and 0600, so it guards against corruption rather than against an adversary.

Four groups are worth knowing as groups:

  • op / op_result / op_cancel carry the op-relay every workspace, MCP and subsession route above runs on. The cockpit sends op with a sequence id; results route back to the waiting request by that id. Chunks are base64-encoded so binary ws raw and ws render output survives the JSON frame.
  • host_op_* (nine kinds) are durable host operations, which survive a gateway restart and a target reconnect. Adding one has four homes — see Architecture.
  • *_tunnel (browser, terminal, host_shell) carry the transport tunnels the /browser/ and /terminal/ routes redeem.
  • agent_message* and agent_roster are agent-to-agent messaging. agent_message_deliver puts a card on the receiving owner's screen; only /api/agentmsg/engage turns it into a turn.

register carries the authoritative routing key and is the frame the gateway authorises a dial-in on — see Channels for lanes and The webview gateway for what that check is and is not.