Multi-host fleet¶
Adding a second host to an existing fleet, renaming one after an OS hostname change, and relocating a live agent from one host to another.
One shared GitOps repo drives many hosts. The ledger is sharded: agents.yaml at the repo
root holds the host: block and the host_keys: age-recipient registry, and each agent
lives in its own agents.d/<name>.yaml file, pinned to a host via run_on:. On each host
apply provisions only its own agents and self-registers that host. Host identity is
always the real os.Hostname() (no host.name scalar is authoritative), so run_on: pins
and the host_keys: registry keys are the machines' actual hostnames.
# agents.yaml (repo root)
host:
teams_gateway: teams-a # hostname of the machine that runs the Teams gateway
host_keys: # fleet age-recipient registry (each host self-registers on join, and on apply)
teams-a: age1… # keyed by os.Hostname()
teams-b: age1…
# agents.d/agent-a.yaml
agent-a: { runtime: pi, channel: { kind: teams, owner_aad: <objectId> }, run_on: teams-a }
# agents.d/agent-b.yaml
agent-b: { runtime: pi, channel: { kind: teams, owner_aad: <objectId> }, run_on: teams-b }
Joining a new host¶
Two roles: NEW is the joining host, REC is any host that is already a secrets
recipient (e.g. the first host in the fleet). Everything below is CLI-only and
idempotent — there is no wizard for this. The one unavoidable manual step is
registering NEW's deploy key on the fleet repo host (GitHub / Azure DevOps).
| # | Runs on | Command | Effect |
|---|---|---|---|
| 1 | NEW | agentctl provision |
Installs this host's OS dependencies (ffmpeg, KasmVNC, …) |
| 2 | NEW | agentctl init |
Creates an empty local fleet repo at /opt/agentctl — not yet connected to the shared one |
| 3 | NEW | agentctl remote add <url> |
Generates a deploy key, points origin at the shared repo, prints the public key to register |
| — | you | register the printed key | Add it as a read-write deploy key on the fleet repo host — the one manual step |
| 4 | NEW | agentctl fleet adopt |
Fetches the shared ledger and resets onto it; registers NEW's age recipient locally (not pushed yet) |
| 5 | NEW | agentctl apply |
Provisions NEW (no agents of its own yet), converges, and pushes NEW's registration |
| 6 | REC | agentctl fleet sync |
Pulls NEW's registration |
| 7 | REC | agentctl apply |
Re-wraps the shared secrets to include NEW's recipient, pushes |
| 8 | NEW | agentctl fleet sync |
Pulls the re-wrapped secrets — NEW can now decrypt them |
| 9 | NEW | agentctl agents add <name> … |
Adds an agent here, auto-pinned run_on: <hostname> |
| 10 | NEW | git -C /opt/agentctl push |
agents add commits locally but does not push — this publishes it |
| 11 | gateway (Teams only) | agentctl fleet sync && agentctl apply |
Gateway learns the new agent's route |
# ── on NEW ────────────────────────────────────────────────────────────────────
# 0. Install the agentctl deb (apt / dpkg -i), then steps 1–3:
sudo agentctl provision
sudo agentctl init
sudo agentctl remote add git@github.com:<you>/<fleet-repo>.git
# → prints a public key. Add it as a READ-WRITE deploy key on the fleet repo
# (GitHub: repo ▸ Settings ▸ Deploy keys ▸ Add, tick "Allow write access").
# 4–5. Adopt the shared ledger, then provision + push the registration:
sudo agentctl fleet adopt
sudo agentctl apply
# ── on REC (an existing recipient, e.g. the first/gateway host) ────────────────
# 6–7. Pull NEW's registration, then re-wrap the secret store to include it:
sudo agentctl fleet sync
sudo agentctl apply
# ── back on NEW ────────────────────────────────────────────────────────────────
# 8. Pull the re-wrapped store:
sudo agentctl fleet sync
# 9–10. Add this host's own agent, then publish it (agents add doesn't push on its own):
sudo agentctl agents add agent-x --channel teams --runtime pi \
--owner-aad <owner-object-id> --provider openrouter --model <id> --api-key <key> --no-admin
sudo git -C /opt/agentctl push
# ── on the gateway host (Teams only) ───────────────────────────────────────────
# 11. Gateway learns the new agent's route:
sudo agentctl fleet sync && sudo agentctl apply
A host that already has working SSH access to the fleet repo some other way can skip
steps 2–3 and clone directly instead: agentctl init --git <url> does the equivalent
of init + remote add + fleet adopt in one step, then continue from step 5.
- Writeback: once
originis set,applypulls first — a forced merge (git pull --no-rebase --autostash --no-edit), never a rebase, so a mid-flight pull can't strand the tree in a detached HEAD — then pushes. It refuses to push a tree with merge-conflict markers (resolve withgit checkout -- <file>, then re-apply). - Secrets: the per-owner
secrets.d/*.sops.yamlshards are each encrypted to every host's age recipient; the re-wrap runssops --config <repo>/.sops.yaml updatekeysper shard (the--configis required). Per-owner sharding means concurrent secret writes to different agents touch different files, with no shared-blob conflict. The age private key stays host-local. - Teams cross-host: the gateway binds
0.0.0.0; a remote agent dialsws://<teams_gateway>:3978/registerover the private network. Keep that port off the public IP (NSG/firewall); the public proxy fronts only/api/messages. Multi-host works forclaudeagents too (only the gateway piece is pi-specific).
Renaming a host¶
The stable identity is the host's age recipient (the private key survives an OS rename), so after hostnamectl set-hostname <new> just run:
sudo agentctl host rename # finds the old name by pubkey match, new = os.Hostname()
sudo agentctl apply # converge units to the new identity
sudo);
the ledger commit + push then happens in agentd's git credential context, like every other
ledger mutation.
It rewrites the host_keys: registry key, every agent's run_on: pin, and host.teams_gateway, then commits + pushes. Use --from <old> / a positional <new> to override the auto-detection.
Agent relocation¶
agentctl migrate relocates one agent to this host from a source agents.yaml, over SSH or from a local path. It does a fresh agents add on the destination, re-activates the agent's declared tools / skills / subagents / cron, rsyncs live state (workspace + git, Claude sessions/transcripts, native memory with the project-dir key remapped), and moves the OAuth tokens, so no re-login. It excludes node_modules, caches, browser profiles, and host-bound secrets. --cutover stops the source first (agents stop-self over SSH); without it the source keeps running so you verify the destination first. Per-agent MCP secrets aren't auto-carried; migrate prints exactly which secret set --agent calls the destination needs.
agentctl migrate you@<host>:22 ./agents.yaml --agent assistant -i ~/.ssh/id_ed25519
Reaching the rest of the fleet¶
Once hosts share a ledger, you do not need a shell on each one. Two mechanisms, and which you
use depends on whether you are addressing a host or an agent. Both are operator scope
(root, or an agent flagged admin: true) and both ride the target's own dial-in connection to
the web-view gateway — no SSH key, and a target behind NAT needs no inbound route.
A host¶
Name the host, and the verb runs as root there:
agentctl host list # every host, and the agents pinned to each
agentctl host job run <host> unit status agentd.service
agentctl host job run <host> host disk
agentctl host job run <host> apply
host job run prints a job id and then follows. Ctrl-C detaches without cancelling; pick it
back up with agentctl host job logs <host> <job-id> --follow. The exit code is the verb's real
one on the target.
An agent¶
Name the agent, and the host is looked up from its run_on: pin. The command is spelled the
same whether that agent is here or three hosts away:
agentctl mem magi # magi's memory, wherever magi lives
agentctl ws cat magi notes/plan.md
agentctl cron list magi
agentctl inject magi "the deploy finished"
agentctl mcp call google gmail_search_emails magi --params '{"query":"is:unread"}'
The operation runs as that agent's own uid on its own host, never as root — it is reading and writing one agent's private world, so it holds exactly that agent's privileges.
Three verbs resolve their host automatically too, so they need no wrapper either:
agentctl apply <agent>
agentctl agents restart <agent>
agentctl login <provider> <agent>
Every host at once¶
agentctl fleet apply # one `apply` host job per host in the ledger
agentctl fleet upgrade # one `host upgrade` per host, then reconcile
agentctl fleet apply --concurrency 1 # sequential, when per-host output matters
Four hosts at a time by default. Each host's state change prints one clock-stamped line as it happens, and a host that fails does not stop the others — read the per-host lines, not just the closing summary.
For the diagnostic sequence to run when a host misbehaves, see Troubleshooting § Cross-host and fleet-wide.