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.