Skip to content

Architecture

A root control plane provisions each agent as its own Linux user with a fixed set of systemd units and on-disk locations. Port bands are in Ports and the port guard; channel transports are in Channels.

── one Linux host ─────────────────────────────────────────────────────────

CONTROL PLANE (root)
  agentd.service ── /run/agentctl.sock        root:agents 0660, SO_PEERCRED gate
     runs native Go reconciles against the desired state + host-local files:
       /opt/agentctl/        GitOps repo: agents.yaml + agents.d/ shards,
                             secrets.d/ store, and mcp/ skills/ agents/ hooks/ tools/ overlays
       /etc/agentctl/        host-local: age.key, env/agent-<n>.env (rendered)
       /usr/share/agentctl/  shipped payload: helpers, units, templates, scripts, catalogs
       /var/lib/agentctl/    runtime: chrome profiles, MCP catalog, tool catalog
        │
        │  provisions, per agent: one Linux user + one systemd unit set
        ▼
AGENT  "danai"          own uid · /home/danai · confined to agents.slice
  agent@danai.service .............. the agent process (claude), PTY-wrapped
     ├─ sources /etc/agentctl/env/agent-danai.env   (bot token, chat id)
     ├─ state in ~/.claude/ (sessions, skills, settings) + ~/workspace/ (zk)
     ├─ connects to ─► mcp-<mcp>-danai.socket     one socket per activated MCP
     │                                            (created by `agentctl mcp add`)
     └─ drives (CDP) ─► browser@danai.service      headful Chrome, profile
                        /var/lib/agentctl/chrome/danai, on its OWN display:
                        xvnc@danai (KasmVNC) + openbox@danai
  agentctl-<job>@danai.timer ....... per-agent timers: zk-index, dream-cycle, …
  agentctl-*-watch@danai.service ... narrate + rate-limit log watchers
     pi runtime → pi-agent@danai.service instead (home ~/.pi/agent/, same MCP sockets)

HOST-WIDE
  agentctl-hostcron-<label>.timer .. fleet-usage, job-watchdog, load-monitor, runaway-reaper, …
  agentctl-notify-failure@<unit> ... any unit fails → Telegram alert

Control flow

        operator (shell/sudo)  ──┐
        admin agent  ───────────┤   agentctl (CLI, thin client)
        regular agent ──────────┘        │
                                         ▼
                                 agentd (root daemon)   ◄── authz boundary
                                         │
                       ┌─────────────────┼─────────────────┐
                       ▼                 ▼                 ▼
                  host-base   agent-add / pi-agent-add   mcp-add    (native Go reconcile)
Component Role
agentctl Thin CLI client; non-interactive, flags plus stdin, machine-readable output
agentd Root daemon; owns config and secrets, and is the authz boundary
Reconcile Native Go, in-process: provision, agents add, apply/converge, mcp add
Payload /usr/share/agentctl/{helpers,units,templates}, rendered by the converge

Dispatch and locking

Property Behaviour
Socket /run/agentctl.sock, root:agents 0660
Caller identity uid from SO_PEERCRED, kernel-supplied and unforgeable
Order State-changing reconciles run FIFO and synchronous
CLI Blocks, streams output, returns the real exit code
Async opt-outs --async, queue, status
Host lock flock(2) on /run/agentctl/reconcile.lock (internal/hostlock)
Lock scope Also taken by a direct apply and by agentctl-hostjob@<job-id>.service
Lock release The kernel releases it when the holder dies
login Runs outside the FIFO, under a per-agent lock
Ctrl-C Cancels the reconcile; the subprocess has its own process group
Sudoers None — socket plus peercred replace per-agent drop-ins

The verb surface is in agentd and the unit model.

Config

Desired state lives in one git repo, default /opt/agentctl, created by init. The CLI mutates it in place and auto-commits every change.

Repo path Holds
agents.yaml Host block plus host keys
agents.d/<name>.yaml One shard per agent: its complete declared state
defaults.yaml Host overlay: <category>: {add: […], remove: […]}
secrets.d/*.sops.yaml sops-encrypted secret store, one file per owner
mcp/ skills/ agents/ hooks/ tools/ cron/ Overlay dirs

Sharding per agent and per owner keeps writers on disjoint files, so git auto-merges and many hosts drive one repo. /etc/agentctl holds only host-bound artifacts: age.key, never in the repo, and the rendered plaintext env the units source.

Layers and precedence

MCPs, skills, subagents, hooks, tools and cron descriptors resolve repo-first across three layers. Config layering is the full treatment.

Layer Root Precedence
Shipped /usr/share/agentctl/{skills,agents,hooks,mcp,tools,cron} The catalog from the deb
Repo overlay /opt/agentctl/…, committed to the GitOps repo Wins over shipped, same name
User-local ~/.claude/skills/<name>/, or pi's ~/.pi/agent/ Coexists; never pruned

The ledger records deltas, not resolved lists

For skills, subagents, cron, hooks and tools, the shard records only the agent's divergence from the defaults. The ledger has declared versus deployed.

Rule Detail
Recorded activate: (add) and deactivate: (remove), never the resolved set
Effective set ( deb_defaults ∪ overlay.add − overlay.remove ) + agent.add − agent.remove
Computed At apply/render time, so a newly-shipped default self-propagates
apply Re-minimizes deltas; self-heals deleted or renamed catalog entries
Prune Removes only catalog-owned symlinks the ledger no longer lists

agents add seeds the default skills and subagents, the crons zk-index, workspace-maintenance and dream-cycle, the universal hook set, and the default MCPs for the agent's channel. token-usage is opt-in.

MCP catalog vs drop-to-add

Only MCPs use an operational catalog: they are external packages that must be version-pinned, vendored and checksummed.

MCPs Skills, subagents, crons, hooks
Added by mcp catalog add/upgrade/remove A file drop in the payload dir or overlay
Discovery The pinned catalog on that host Auto-discovered
Activated by mcp add agents skills/subagents/hooks/cron add
Activation does Creates the MCP's unix socket Symlinks the item into the agent's home

Activation is where the two runtimes stop matching. A claude agent gets every tool of an activated MCP declared to its model; a pi agent reaches them from a model-written script instead, which is also how it reaches its built-in tools in the same run — see codemode.

Hooks are per-agent: a registry maps each short-name to {event, script, timeout}, and the agent's hooks: list selects them, defaulting to the universal set.

Default MCPs: the credential rule

A catalogued MCP becomes a deb default only if it carries no credential — auth: none and an empty required_env in its own mcp.yaml. A credential surface (notion, ms365, perplexity, exa) defaults only through a host overlay, where the operator who owns the account makes the call.

Two entries clear that bar, and the second is channel-conditional:

--channel deb default MCPs
webview browser
telegram browser, telegram
teams browser, teams
slack browser, slack

An agent is never handed another channel's MCP. TestEveryDefaultMCPCarriesNoCredential reads each default entry from its shipped manifest and fails the build if it declares auth or wants an env key.

browser Detail
Package The Chrome team's chrome-devtools-mcp, vendored under the name browser
Mode Attach-only via --browser-url; it disconnects on shutdown, the profile outlives it
Port From root-owned /etc/agentctl/chrome/chrome-<agent>.env, never a tool parameter
Runtime Both. A pi agent wires the same catalog MCP over the same socket, declared under the same tools: key
Opt-out agentctl mcp remove browser <agent>; fleet-wide, agentctl defaults remove tools browser

Wiring an MCP into a claude agent rewrites ~/.claude.json, which is read only at start. So the first apply after a change to the default set restarts every claude agent on the host.

Crons and scheduled jobs

Two scopes, both systemd timers, both auto-discovered from the shipped unit files.

Per-user Host
Unit agentctl-<job>@<agent> agentctl-hostcron-<label>
Declared in The agent's shard host.cron in agents.yaml
Files Templates in cron/units/, logic in cron/ A descriptor dir cron/host/<label>/
Seeded zk-index, workspace-maintenance, dream-cycle (token-usage is opt-in) job-watchdog, load-monitor, runaway-reaper, fleet-usage (hourly token/active-time rollup the fleet app reads)
Manage agentctl agents cron add/remove <job> <name> agentctl host cron add/remove <job>; list with --host

Adding a job is a file drop, no rebuild. Per-user: <job>.sh plus the agentctl-<job>@.{service,timer} templates. Host: cron/host/<label>/cron.yaml with schedule: and script:/skill:, plus the script; a default also needs its label in the defaultHostCron seed. cron add validates that the unit, the timer and the ExecStart script exist before enabling.

The log is the truth

The source of truth is append-only files on disk, written by the agent, on the agent's host. A frame is the live push of an event that is already in the log.

Invariant Consequence
Every durable, owner-facing frame is reconstructible from the log Write the event first, or list the frame as exempt
The log format belongs to the runtime agentctl parses claude and pi session JSONL, it does not define it
The cockpit gateway never writes durably It is stateless, cross-host and replicated
Session kind is fixed at birth It is an append-only event, not a mutable field
Durability is the log's, not a reader's position in it A cursor is durable only when its advance is an acknowledgement

Writers

Writer Records Detail
claude / pi runtime Session JSONL Written whether or not agentctl exists
runNotify Sends not otherwise recorded Records after target resolution — self, <agent> and each holder of admin (a set, fanned out one delivery per holder) all record identically
narrate.py {kind:"notify"} to the shim socket Bypasses runNotify; it mirrors blocks already in the transcript
session-event.sh session_started, session_rotated Carries an explicit self-exclusion

session_started{session, origin} covers all three lanes — main, one shared cron lane, each subsession — and session_rotated{from,to} claims both halves. The reader keys on lane, not session id; .cronskip and .subskip are the fallback when a session has no record. claude combines hooks across --settings sources, so a settings.json hook also fires in cron runs and subsessions: hence the self-exclusion.

Cursors

Cursor Scope Answers
Positional (session, seq) Durable for narrate.py, resolved by a seek "This file from index N"
Merge key "<millis>:<ord>" Scroll-back, intrinsic to the record "The page older than this"

The gateway's cursor lives for the SSE connection, the browser's for the tab. ~/.claude/state/stall/state.json is the legacy shape: a crash mid-write corrupts it. Leave it alone. turn-review-state.json now writes through a temp file and a rename, so a crash leaves the previous marker rather than an empty one.

Its markers stay single-writer: the turn-review runner (turn-review.py, and its pi twin turn-review-run.ts) owns every advance. The one seam is --transcript <path> --mark-mined-through N, for an out-of-band mining process that mined a starved session's tail itself — not for general code. It runs no review, and the marker is monotonic, so it can never hand a reviewed span back.

Exempt frames

Exempt Kind
stop / compact signals Liveness state, meaningless a second later
pi stream_id token deltas A preview; the finalized block is what gets recorded
op / op_result Workspace-browse RPC: request/response, not history
register / hello handshakes Transport plumbing, not agent history
Browser scroll / tab state Client-local view state; each tab has its own

Exemption is by kind, not by convenience. The test is whether the frame's absence from scroll-back would be a bug.

Reaching another host

Two mechanisms, addressed differently because they run with different privileges. Both are operator scope: root, or an agent flagged admin: true.

Host job Agent administration
You name a host an agent
Grammar host job run <host> <verb> [args…] the ordinary verb, e.g. ws cat <agent> <path>
Runs as root on the target host that agent's own uid, never root
Reaches the host's own diagnostics and control surface one agent's workspace, memory, skills, MCPs and live session
Target host typed by hand resolved from the agent's run_on: pin
Catalog 62 verbs 32 operations

Neither crosses hosts over SSH. Both ride the target's own dial-in connection to the web-view gateway, so a target behind NAT needs no inbound route and the source needs no key.

Host jobs

host job run persists a receipt on the target before anything executes, so a job survives an agentd restart, a gateway bounce and a reconnect. Output is read back from the target's own journal rather than from a central store.

Piece Fact
Target unit agentctl-hostjob@<job-id>.service, a shipped template, never enabled
Unit flags Type=oneshot, Restart=no, TimeoutStartSec=0, CollectMode=inactive-or-failed
Start order agentd starts the unit only after the receipt is persisted
State root /var/lib/agentctl/hostops/target, 0700 root:root
Stores target.db, output/<job-id>.jsonl, and the worker's ops/<job-id>/spec.json
Worker input only the root-owned 0600 spec.json — the verb and its arguments never reach a command line
Retention 72 h of output, 14 d of metadata
Caps 1 GiB of output per store, 256 MiB of it reserved for non-terminal jobs

Plan roughly 1 GiB of headroom under /var/lib/agentctl/hostops per host.

The grammar is host job run|status|logs|cancel, in the CLI reference. hostjob-worker run is the unit's ExecStart and is not a command to invoke by hand.

agentctl fleet apply and agentctl fleet upgrade are this same primitive in a loop — one host job per host in the ledger, four hosts at a time, with per-host status printed on transition. A host that fails does not stop the others.

Agent administration

The 32 agent-administration operations each name a target agent, and that agent is pinned to exactly one host — so the host is derivable and is never typed. agentctl mem magi is the same command whether magi is on this host or another one.

Piece Fact
Execution runuser -u <agent> on the target host, then the ordinary verb
Identity the target agent's own uid; uid 0 is refused at the primitive, whatever a grant says
Environment a fresh allow-list — HOME, PATH, AGENTCTL_CALLER, and proxy routing variables if set
Path resolution happens only inside the dropped child, never root-side
Mutating members ws write, ws mkdir, ws mv, ws rm, inject, mcp call — the other 26 are reads
Binary output ws raw and ws render stream base64 chunks, so images, PDFs and video survive the wire

Same-host admin-to-peer traffic takes the same path as cross-host traffic; there is no faster local route. An agent reading its own data stays in its own process.

host operation is gone entirely. status, logs and cancel read only the retired coordinator's own store; run survived one phase longer as a transient relay for privileged reads, and was removed once its last consumer — the fleet activity-protocol collector's cross-host pull — moved to host job run. There is no relay left and no fallback: every host-addressed verb runs as a job on the target, with its own receipt. host op still refuses out loud and names host job run.

Keys

Two HMAC keys, both 0600 root:root, auto-seeded and rendered by apply on every host.

Key File Read order
AGENTCTL_HOST_OP_SECRET /etc/agentctl/env/hostop-capability.env File, then environment
AGENTCTL_HOST_OP_GRANT_SECRET /etc/agentctl/env/hostop-grant.env Environment, then file

A host missing the file refuses every incoming job rather than falling back. Agent administration carries its own sibling credential, minted per operation and binding the target agent, the argument vector and any request body. 640 root:agents on either file, or any hit for AGENTCTL_HOST_OP_*_SECRET in /etc/agentctl/env/mcp-shared.env, is a key compromise: rotate on every host.

Locks and cancellation

Verb class Lock
Privileged read None
Converge-exempt Keyed per-agent flock at /run/agentctl/agent-locks/<key>.lock
Everything else The fleet lock

Per-agent dispatch refuses after that verb's backstop and names the agent and the lock.

Cancellation is a column, not a state. Only the submitting source may cancel, and cancelled arrives only from a target's own terminal report. The CLI exits 75 (EX_TEMPFAIL) while a cancellation is requested, and 0 once it has settled.