Skip to content

The cockpit

Everything the browser surface does: the tabs an owner lives in, the mini-app framework behind the launcher, the private display the browser view needs, and the admin-only terminal. Standing it up is part of Quick start.

agentctl top --web --gateway serves an installable-PWA cockpit: the workspace each person actually lives in. Front it with your own HTTPS proxy.

surface what it is
chat a live single-socket session stream (assistant text + tool calls in transcript order), cron runs as notification cards interleaved by time, inbound + agent-sent attachments inline, slash-commands for skills, infinite scroll-back across every session and cron, permission Allow/Deny prompts as cards
workspace the agent's ~/workspace browsed and edited in place: read, write, drag-to-move, upload. Office docx/xlsx/pptx open in an Office tab (agentctl office)
sessions subsessions as their own chat tabs, tileable side by side (drag a tab to split); right-panel def/session tabs for skills, subagents, crons and hooks
mini-apps a launcher of panels reading live data from the agent's wired MCPs (below)
browser a live drivable view of the agent's real Chrome, on its own display

It is also the web-view channel itself: webview-gateway install stands up the host WS router that channel=webview agents dial into (protocol frames per runtime/channel/protocol.ts), and webview-login install adds the browser-facing login proxy. Ports 8735 and 8736, both bound locally behind your proxy. Both are host services that apply / fleet sync reconcile like the other gateways: a deb upgrade force-bounces them onto the new binary, and a rotated WEBVIEW_* sops secret re-renders their env. See Channels.

Subsessions are a first-class session dimension: an agent (or the owner, from the cockpit) can run up to 3 concurrent headless worker sessions, each on the web-view channel only (never the owner's chat platform), driven on demand and addressed by a user-given name. Unlimited total, at most 3 running, idle and stopped kept and resumable. The main agent uses subsession prompt + tail to orchestrate its own workers. claude is fully supported; pi is symmetric via the web-view-mode bridge. See Subsessions below.

Mini-apps

A mini-app is a self-contained panel in the cockpit (manifest.json + app.js) that reads live data from an MCP the agent has wired and paints it. This is how the workspace gets shaped to one person's actual job: a mail list, a CRM record, a Jira board, a portfolio tile.

Three sources, most-specific-wins on an id collision:

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

A user app is the app analog of a local skill: per-agent, user-writable, no rebuild and no apply. Because it lives on a home that is remote to the gateway, the cockpit sources it by running a self-scoped read as the agent (apps list / apps bundle, over the op-relay or SSH), which is also why one agent's user apps can never be loaded for another. Overlay apps are served from the gateway host's clone, and nothing auto-pulls it.

Which apps light up for a given agent is derived from the MCPs that agent has wired, and an app's data plane is read-only unless its manifest says otherwise.

Activation, admin apps and the read-only data plane

Activation is derived, never declared. An app that declares providers lights up for an agent iff its providers[].match (an MCP-name prefix) matches ≥1 MCP wired for that agent. So mcp add|remove <mcp> <agent> is the per-agent decision about who gets which surface. Two MCPs of one family (google-workspace, google-workspace-personal) surface as two switchable accounts.

An app that declares no providers ("providers": []) is self-contained: it reads no MCP, activates for every agent, and resolves zero accounts. That is the shape of a converter, a checklist or a scratchpad: a surface whose state is its own DOM and, if it declares writes, the agent's workspace. In the browser an empty accounts switches the chrome to the reserved "self" adapter (or to no data source at all, in which case paint runs once and the app owns the body), and the drawer groups these under local. apps active reports them as active — self-contained; apps validate flags only the contradiction of an empty providers list beside an app.js that calls ctx.mcpRead.

An app may also declare "admin": true, which restricts it to a viewer holding the admin role: the fleet operator, not an agent's owner. fleet is the only one: every agent on the host, live, on the cockpit's existing SSE stream. It is the one activation input that is not a property of (manifest × wired MCPs), because it is about who is looking rather than about the agent, so the filter runs at the launcher endpoint where a request identity exists.

It carries three tabs. Agents is the roster as one row per agent, sorted down-first, with logs (journal + live session), files (~/workspace) and config (credentials, MCP sockets, secret key names, skills, jobs) as detail surfaces. That is what the retired top dashboard's side drawer held, which is unreachable from a cockpit that covers it. open opens the agent in a new tab via /?agent=<name>&host=<host>, honoured by the shell only for an admin. Usage (token spend per agent) and Active hours (agent-hours by hour of day, plus each agent's share of an 8.4h working day) read one small rollup per agent (~/workspace/.cache/fleet-usage-rollup.json, over the same admin-scoped /api/file plane that reaches a dialed-in agent on another host) and cache it per agent in localStorage. The rollup is produced by /usr/share/agentctl/cron/host/fleet-usage/fleet-usage-rollup.py, incrementally (byte offsets per transcript, a per-day minute bitmap for the 30-minute idle rule). It is a default host cron at :37, seeded by provision and by apply like the reaper, so install deb → apply activates it everywhere.

Active hours counts only the sessions a HUMAN drove: the producer classifies each transcript against the session recorder's cron lane (origin:"cron", a recorded fact rather than a heuristic) and publishes activeMinIa beside the total. The tab prints what it removed, how far back the classification is trustworthy, and the age of the parse behind the number. A stopped cron looks exactly like a working one until something says otherwise.

It is a launcher filter, not an authorisation gate: hiding an icon protects nothing, and the data an admin app reads is gated per endpoint (adminScope). A user app cannot declare it: the reader clears the field, on the same principle that makes it stamp the user source itself rather than trusting the file. A manifest on an agent's own home may not describe its own privileges.

The data plane is read-only by default (ctx.mcpRead, turnless, gated on isReadOnlyToolName at both the browser and the server), which is what makes a mini-app bug a rendering bug rather than a data-loss bug. Two per-app opt-ins widen it, each declared in the manifest and independently re-checked server-side: writes: true grants ctx.ws.* (the same /api/{write,mkdir,rm,mv,upload} endpoints and the same AgentWritePath containment the workspace file tree already uses; only video sets it), and actions: true grants ctx.mcpCall for a mutating tool on the narrow action plane. The action plane is narrowed per MCP, per tool, per arg where the MCP boundary alone is too coarse: browser is admitted with take_snapshot and the single arg verbose only. evaluate_script and take_snapshot's filePath (an arbitrary file write reachable from a browser tab) stay unreachable.

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

Pinned apps. The header strip is the viewer's own shortcut row: pin an app from the drawer (the pin beside each row) and the strip becomes exactly the pinned set, in pin order; until anything is pinned it stays every active app. Pins belong to the person, not to the agent on screen (an admin opening another agent's cockpit keeps their own), so they live in the viewer's own ~/workspace/.cache/cockpit-prefs.json ({"version":1,"pinnedApps":[…],"seen":{…}}), written over the same /api/file + /api/write plane the file editor uses, with localStorage mirroring it so the strip paints before the file lands. A pin for an app that is not active right now is skipped in the strip and kept in the file.

Unread. The same file carries seen, one wall-clock number per "<agent>/<lane-id>" saying when the owner last looked at that lane (the main lane's id is "", which is stable across a session rotation; the real sid is not). The sidebar and the picker paint a dot on any lane whose server-reconciled last_active is newer than its mark, and the transcript shows a new divider at the first item newer than the mark the lane was opened with, so the line does not chase the tail while you sit watching, and the next visit finds nothing to draw. The mark advances on looking (session tab, visible document, pinned to the bottom), never on sending. It is stored on the server precisely so it crosses devices: read a lane on the desktop and the phone stops showing it as unread. Two cockpits open at once reconcile by max, never last-writer-wins. /api/sessions carries a synthesized kind:"main" entry for the main lane's last_active, which is where the main row's dot comes from: the main lane has no registry sidecar, so nothing else could report it.

Authoring guide: the shipped mini-app-designer skill (framework, loading model, ckApps host API, render scaffolds, mobile ship gates). Host-side seams: internal/cli/apps.go and internal/cli/apps/_harness/README.md.

Per-agent private display

Every agent has its own X server: xvnc@<agent>.service, KasmVNC's Xvnc. This is what makes the cockpit browser mini-app (a live view of that agent's real Chrome) possible, and it is not optional. Know what that route hands out before the first opt-in: /browser/ gives whoever reaches it keyboard and mouse on a persistent, logged-in Chrome, and the cockpit session is the only boundary.

It used to be. Every headful Chrome on a host shared one X server (xvfb.service on :99, fixed at 1920x1080), and private_display: true moved one agent off it. That flag was written down as transitional scaffolding from the day it shipped, and it is now retired: the ledger key is gone, agents set rejects it, and xvfb.service / xvfb.target are deleted from the host role because they have no consumers left. A shared display cannot be shown to a human at all (any display-level transport of :99 shows every agent's windows to whoever is watching), so keeping it as an option meant keeping a mode in which the browser view is unshippable.

Units, ports, containment and the upgrade path

units per agent xvnc@<agent>.service (KasmVNC's Xvnc, which is an X server: it replaced xvfb.service rather than stacking on it) + openbox@<agent>.service (the WM; without one a file picker lands at 0,0 undecorated and unclosable)
the per-agent bytes one per-instance drop-in, /etc/systemd/system/browser@<agent>.service.d/10-private-display.conf. The shared browser@.service template is never edited. The drop-in adds Requires=/After=/PartOf= (a drop-in can only add a dependency, never remove one; systemd.unit(5)) and RESETS Environment=, which is not a dependency and therefore does reset
allocation /etc/agentctl/display/display-<agent>.env carries DISPLAY=:<100+uid-1000>, DISPLAY_NUM, KASM_PORT=<22730+uid-1000>. It is the single source of the display number, so xvnc@ and browser@ cannot disagree about it
containment Xvnc runs -SecurityTypes None, so the transport is unauthenticated to every local uid. It is bound loopback three independent ways (-interface 127.0.0.1, -localhost, and kernel-enforced IPAddressDeny=any/IPAddressAllow=localhost on the unit cgroup); the cockpit's authenticated /browser/ proxy is the identity gate
upgrade the first apply after this release removes each agent's retired 10-shared-display.conf, disables+stops xvfb.service/xvfb.target, and restarts browser@<agent> onto its own display. Agents that had already opted in converge to a no-op

There is deliberately no per-agent rollback. private_display false existed so a canary could be walked back; with xvfb deleted from the host role, an escape hatch to a tier the host no longer runs is not an escape hatch; it is a Chrome ordered against a display that does not exist. The thing to roll back is the release.

The cockpit surface it feeds. The cockpit mounts /browser/, 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 the same Chrome over CDP; neither knows about the other. The proxy asks the drop-in's existence (the converged artifact: an agent in the ledger that has not been applied yet has a KASM_PORT and no Xvnc) whether an agent's display is actually up, then reads KASM_PORT out of that agent's own env file. There is no port parameter on the route by construction, and the upstream host is a constant 127.0.0.1. The route is mounted under a sub-path rather than at the origin root (Kasm's client references its assets relatively, so it does not need the origin root), its cookie names a target agent and never authority (every request re-runs the same identity check as the rest of the cockpit), and the session TTL is 2h.

Know what is behind it before the first opt-in. /browser/ gives whoever reaches it keyboard and mouse on a persistent, logged-in Chrome: every live session in that profile, a password manager that may already be unlocked, and a file picker rooted in the agent's home. There is no second factor; the cockpit session is the boundary. Opt in one agent first; that first opt-in is also the first end-to-end test of the route.

Subsessions — the side-by-side worker tabs

A subsession is a headless session the main session drives, shown in the cockpit as its own chat tab. Two mechanisms keep it from interfering with the owner's own conversation:

  • Ingress is per-lane. The primary lane binds the fixed ~/.claude/channels/events.sock; a subsession (one with CHANNEL_SESSION set) binds its own <sid>.sock, so it cannot steal the main session's ingress.
  • The sidecar stores INTENT, and status is reconciled on READ. The reader never writes back: it overlays systemd's ActiveState, the session event log and the transcript mtime to derive failed, concluded (the session ended itself) and resumable: false (no transcript, so claude --resume cannot attach). None of those three are values the sidecar ever holds.

Cron runs are deliberately not in the subsession registry — writing them there would leak every scheduled run into the subsession UI.

The terminal — an on-demand shell, admin agents only

The cockpit also mounts /terminal/: 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 deliberately a copy of its identity gate, so the two can be reviewed against each other. It is also more dangerous than /browser/ — not keyboard and mouse on a browser, but a shell: every secret that agent can read, every system it is wired to, and on the admin agent the whole fleet.

Units, the admin marker, addressing and lifecycle

backend ttyd (vendored static binary, pinned in internal/converge/versions.go): xterm.js plus a websocket-to-PTY bridge in one MIT-licensed file. Not built here, for the same reason KasmVNC is vendored whole
units per agent none at rest. One host-global template, terminal@.service, with no [Install] section, so it cannot be enabled, only started, and only by the proxy. The steady state on every host is zero processes
the per-agent bytes one marker, /etc/agentctl/terminal/<agent>.admin, written for an agent whose ledger entry carries admin: true and removed for one that does not. Granting/revoking the role and applying is the whole enablement/teardown
addressing a UNIX socket, /run/terminal-<agent>/tty.sock, in a per-agent RuntimeDirectory at mode 0700. There is no port anywhere on this route: the destination is a pure function of the agent name, so unlike /browser/ it cannot grow a port parameter even by accident
containment three independent layers: the launcher hides the icon from a non-admin viewer (admin: true, a launcher filter and not a security boundary); the proxy re-derives authority per request and re-checks the marker on every proxied request, so revoking the role closes a session already inside its cookie TTL; and the unit AssertPathExists= the marker, so systemd itself refuses a non-admin agent's shell even if the cockpit is bypassed, wrong, or replaced
lifecycle started on demand by /terminal/enter, which waits for the socket to bind. ttyd --exit-no-conn exits when the last client disconnects (SIGHUP takes the shell with it), and that is the whole reaper; /terminal/exit also stops the unit on tab close, and RuntimeMaxSec=12h is a backstop for a viewer that vanished before its websocket ever connected
cross-host the same tunnel /browser/ uses (internal/cli/terminal_tunnel.go), and it carries less: the doorbell frame is a token and nothing else, and the handshake coming back is ok/error with no port, because there is no port on this route to report. The agent's always-on tier dials back and pipes its own tty.sock; the gateway names no host, no port and no socket. The admin role is re-checked on the agent's own host on every tunnel (terminalAdminOK runs there), cached at the gateway for 30s, so a revoked admin stops working within 30s cross-host, against immediately on the local path
the one new privilege the agent-side tunnel runs as the agent's own unix user, which cannot start a system unit. It hops its own local agentd via agentctl terminal up self, a self-scope mutating verb, peercred-gated exactly like chrome restart. The delta for the caller is zero: the unit it starts runs $SHELL -l as that same uid on a socket in that same uid's 0700 runtime dir, which the user could already do by typing sh. A non-admin caller passing that gate still gets a start systemd refuses (AssertPathExists= the root-owned marker)
still not built cross-host /terminal/exit does not stop the unit: the unit is on the other machine. The ordinary tab-close is still reaped by --exit-no-conn; what a remote agent loses is only the belt-and-braces case (an enter whose websocket never connected), which falls back to RuntimeMaxSec=12h. Closing that needs a teardown verb on the agent host

ttyd runs with no --credential, for the same measured reason Xvnc runs -SecurityTypes None: HTTP Basic auth cannot be driven from inside an iframe, so a credential there would not be a second factor, it would be a broken app. The socket is unreachable by the other agent uids on the host, which is the one place this route is stronger than the browser's loopback TCP port.