Skip to content

The cockpit

agentctl top --web --gateway serves the cockpit: an installable PWA scoped to one agent per authenticated person. It is reached over HTTPS — either the Caddy apply manages on the gateway host or a proxy you run in front of :8736, since the session cookie is Secure and has no plain-HTTP mode (TLS and Caddy). For the first-time walkthrough see First run.

Surfaces

surface what it is
chat a live single-socket session stream
workspace ~/workspace read, written, drag-moved and uploaded in place
office docx/xlsx/pptx in their own tab, via agentctl office
sessions each subsession as its own chat tab, tileable side by side
mini-apps a launcher of panels reading live data from wired MCPs
browser a drivable view of the agent's real Chrome
terminal a shell as the agent's own user, for every applied agent

Chat streams assistant text and tool calls in transcript order, interleaves cron runs as notification cards, renders attachments inline, and raises permission prompts as Allow/Deny cards. Scroll-back is infinite across every session and cron. The right panel carries def/session tabs for skills, subagents, crons and hooks.

The web-view channel

The same binary is the channel channel=webview agents dial into.

service role port
webview-gateway install the host WS router agents dial 8735
webview-login install the browser-facing login proxy 8736

Frame protocol: runtime/channel/protocol.ts. Which interface each port binds, what bounds it, and what apply reconciles all live on The webview gateway. For the chat platforms an agent can carry alongside the cockpit, see Channels.

Mini-apps

A mini-app is a self-contained panel (manifest.json + app.js) that reads live data from a wired MCP and paints it — a mail list, a CRM record, a Jira board.

layer lives in reaches the agent by
user ~/.claude/apps/<id>/ on the agent's home write the files, re-open the cockpit
overlay <fleet-repo>/apps/<id>/ apply
embedded the binary (mail, calendar, video, browser, fleet) a deb upgrade

Most-specific wins on an id collision. The cockpit sources a user app by running apps bundle as that agent, so one agent's user apps never load for another. Overlay apps come from the gateway host's clone, which nothing auto-pulls.

agentctl apps list [<agent>|self] [--json]   # the agent's user apps
agentctl apps validate [<agent>|self]        # why an authored app doesn't appear

Activation

Activation is derived, never declared: an app with providers activates for an agent iff its providers[].match (an MCP-name prefix) matches a wired MCP. mcp add|remove is therefore the per-agent decision about who gets which surface.

An app declaring "providers": [] is self-contained: no MCP, active for every agent, zero accounts — a converter, a checklist, a scratchpad.

self-contained app behaviour
with accounts empty the browser switches to the reserved "self" adapter
with no data source paint runs once and the app owns the body
in the drawer grouped under local
apps validate flags empty providers beside an app.js calling ctx.mcpRead

Two MCPs of one family (google-workspace, google-workspace-personal) surface as two switchable accounts.

Admin apps

"admin": true restricts an app to a viewer holding the admin role — the fleet operator, not an agent's owner. fleet is the only one, and it carries three tabs.

tab shows reads
Agents one row per agent, down-first, with logs, files, config detail the live SSE stream
Usage token spend per agent the usage rollup
Active hours agent-hours by hour, and each agent's share of an 8.4h day the usage rollup

open puts an agent in a new tab via /?agent=<name>&host=<host>, honoured only for an admin. "admin": true is about who is looking, so the filter runs at the launcher endpoint, where a request identity exists. It is a launcher filter and not an authorisation gate: the data behind it is gated per endpoint (adminScope). A user app cannot declare it; the reader clears the field.

The usage rollup

property value
producer fleet-usage-rollup.py, a default host cron at :37, seeded by provision and apply
output ~/workspace/.cache/fleet-usage-rollup.json
incremental state byte offsets per transcript, a per-day minute bitmap for the 30-minute idle rule
read path the admin-scoped /api/file plane, which also reaches another host

Active hours counts only sessions a human drove: the producer classifies each transcript against the session recorder's cron lane (origin:"cron") and publishes activeMinIa beside the total. The tab prints what it excluded and how old the parse is.

The data plane

ctx.mcpRead and ctx.mcpCall are turnless and call any tool on an MCP the agent is wired for — reads and writes alike. They are one plane under two names: the same server-side gate answers for both, so which method an app calls is a readability choice, not a permission. A read-only tool-name gate used to sit in front of mcpRead and an actions: true manifest opt-in in front of mcpCall; the first refused every interactive app on a naming heuristic, and the second stopped meaning anything the moment the first was removed. Both are gone.

What the server still refuses:

refused rule
risk: block tools declared per tool in the MCP's mcp.yaml — the same source the agent-side gate reads. Almost nothing declares it yet
specific argument keys filePath on take_snapshot/take_screenshot, bypassReason on ADO's PR write, approval_password on GitLab's approve — keys that reach the local disk or override a policy
evaluate_script, upload_file arbitrary JS in the agent's logged-in browser, and a file off its disk into a page

Whether a given app should mutate is its author's call. Two per-app opt-ins remain real:

opt-in grants scope
workspace_read: true ctx.ws.{read,hash,rawURL,rawPrefix,downloadURL,saveControl} read-only workspace helpers under AgentWritePath containment
writes: true complete ctx.ws.*, including the read helpers and /api/{write,mkdir,rm,mv,upload} workspace mutation seam

workspace_read is API least-privilege for trusted cockpit modules, not a new same-origin security boundary; every endpoint still enforces viewer-to-agent scope and workspace containment. Only video sets writes.

Pinned apps

Pin an app from the drawer and the header strip becomes exactly the pinned set, in pin order; until anything is pinned it stays every active app.

property value
stored in the viewer's ~/workspace/.cache/cockpit-prefs.json, key pinnedApps
written over the same /api/file + /api/write plane as the file editor
belongs to the person, not the agent on screen
an inactive app skipped in the strip, kept in the file

Unread marks

seen, in that same prefs file, holds one wall-clock number per "<agent>/<lane-id>": when the viewer last looked at that lane.

rule behaviour
the dot painted where a lane's reconciled last_active is newer than its mark
the new divider drawn at the first item newer than the mark the lane was opened with
the mark advances on looking, never on sending
across devices the mark is server-side, so reading on the desktop clears the phone
two open cockpits reconcile by max, never last-writer-wins

Authoring guide: the shipped mini-app-designer skill. Host seams: internal/cli/apps.go, internal/cli/apps/_harness/README.md.

Per-agent private display

Every agent has its own X server. This is what makes the browser mini-app possible, and it is not optional.

per agent what it is
xvnc@<agent>.service KasmVNC's Xvnc, which is the X server
openbox@<agent>.service the WM; without one a file picker lands at 0,0 and unclosable
display/display-<agent>.env DISPLAY=:<100+uid-1000>, DISPLAY_NUM, KASM_PORT=<22730+uid-1000>
browser@<agent>.service.d/10-private-display.conf the per-instance drop-in

The env file is the single source of the display number, so xvnc@ and browser@ cannot disagree. The drop-in adds Requires=/After=/PartOf= and resets Environment=; the shared browser@.service template is never edited.

Xvnc runs -SecurityTypes None, so the transport is unauthenticated to every local uid. It is bound to loopback three independent ways: -interface 127.0.0.1, -localhost, and IPAddressDeny=any / IPAddressAllow=localhost on the unit cgroup.

The /browser/ route

/browser/ is an authenticated reverse proxy to that agent's KasmVNC transport, framed by the browser mini-app. The human drives that Chrome through X input events while the agent drives it over CDP; neither knows about the other.

property value
liveness check the drop-in's existence, not the ledger entry
port read from that agent's own env file, never from the request
upstream host the constant 127.0.0.1
cookie names a target agent, never authority
identity re-checked per request
session TTL 2h

Warning

/browser/ gives whoever reaches it keyboard and mouse on a persistent, logged-in Chrome. That means every live session in the profile, a password manager that may be unlocked, and a file picker rooted in the agent's home. There is no second factor.

Subsessions

A subsession is a headless worker session the main session drives, shown as its own chat tab. It runs on the web-view channel only, never the owner's chat platform.

property value
addressed by a user-given name
concurrent 3, of unlimited total; idle and stopped ones stay resumable
runtimes claude fully supported, pi symmetric over the web-view-mode bridge
driven by subsession prompt and subsession tail
reports back with subsession notify (pi only — see below)

subsession notify <target> <message> <agent>|self is the reverse direction: a subsession telling its own main session — target main, always that literal — or a sibling subsession that it finished or wedged. It is intra-agent only; cross-agent messaging is the separate agent-message verb, which is ledger-gated because it asks a question this one does not.

It writes one file per notification into the target agent's ~/.cache/agentctl/subsession-notify/<bucket>/, keyed by the target session's id (or the literal main, whose real id rotates on compaction and so is never used as a key). A pi extension drains that directory on the context event, which fires before every model call rather than only at a turn boundary — so a notification reaches a target that is busy mid-turn as well as an idle one, which is the failure mode subsession prompt has (a frame delivered to a mid-turn session can be dropped silently). The cost is that delivery happens at the target's next model call, not immediately.

Reading is pi only. On a claude-runtime agent the file is written and nothing consumes it.

Two mechanisms keep a subsession out of the owner's conversation:

mechanism effect
per-lane ingress the primary lane binds channels/events.sock, a subsession its own <sid>.sock
status reconciled on read the sidecar stores intent only, and the reader never writes back

The reader overlays systemd's ActiveState, the session event log and the transcript mtime to derive failed, concluded and resumable: false. Cron runs are not in the registry, so scheduled runs never reach the subsession UI.

The terminal — an on-demand shell, for every applied agent

/terminal/ is an authenticated reverse proxy to a PTY running the agent user's own login shell, framed by the terminal mini-app. It is the same route shape as /browser/ and reaches much further: every secret the agent can read, every system it is wired to, and — on an agent that also holds the admin role, through that agent's own agentd scope, not through this route — the whole fleet.

property value
backend ttyd, vendored and pinned in internal/converge/versions.go
units at rest none; only the proxy starts terminal@.service
enablement the marker /etc/agentctl/terminal/<agent>.admin, written by apply for every agent unconditionally — no longer role-gated, despite the filename
addressing the socket /run/terminal-<agent>/tty.sock; no port on this route
gates per-request proxy check (identity + marker), systemd's AssertPathExists=

Read Terminal access before relying on it: the gates, the cookie, the unit settings, cross-host behaviour, the reaper, and why there is no revoke.