Skip to content

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. agents.yaml pins each agent to a host via host:; 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 in the committed yaml), so host: pins and the hosts: registry keys are the machines' actual hostnames.

host:
  teams_gateway: teams-a        # hostname of the machine that runs the Teams gateway
hosts:                          # fleet age-recipient registry (each host self-registers on join, and on apply)
  teams-a: age1…                #   keyed by os.Hostname()
  teams-b: age1…
agents:
  agent-a: { runtime: pi, channel: teams, owner_aad: <objectId>, host: teams-a }
  agent-b: { runtime: pi, channel: teams, owner_aad: <objectId>, host: teams-b }

Joining a new host

Easier path: the top web dashboard's ⛓ join fleet wizard orchestrates this whole sequence over SSH (prepare deploy keys → seed/adopt → the age-recipient handoff → apply). The manual runbook below is the same steps by hand.

NEW = the joining host, REC = any host already a secrets recipient. Steps are idempotent. The only unavoidable manual hops are the one GitHub deploy key and the pull/apply handoff that exchanges age recipients.

# ── on NEW ────────────────────────────────────────────────────────────────────
# 0. Install the agentctl deb (apt / dpkg -i).

# 1. Fleet git deploy key (the one GitHub step). Generate a key, then register its
#    PUBLIC half as a READ-WRITE deploy key on the fleet repo (writeback needs push):
sudo install -d -m700 /root/.ssh
sudo ssh-keygen -t ed25519 -N '' -C "$(hostname)-fleet" -f /root/.ssh/fleet_deploy
sudo cat /root/.ssh/fleet_deploy.pub      # → GitHub repo ▸ Settings ▸ Deploy keys ▸ Add (Allow write)
sudo git config --global core.sshCommand \
  'ssh -i /root/.ssh/fleet_deploy -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new'

# 2. Join + provision. init --git clones the FILLED ledger (don't init empty) and
#    registers this host: age key, hosts.<hostname>, pushed. Same as seed/adopt do.
sudo agentctl init --git git@github.com:<you>/<fleet-repo>.git
sudo agentctl provision
sudo agentctl apply        # converges. Re-registers if step 2 could not (idempotent).
                           # Agents pinned to OTHER hosts are skipped.

# ── on REC (an existing recipient, e.g. the first/gateway host) ────────────────
sudo git -C /opt/agentctl pull --rebase
sudo agentctl apply        # sees 2 recipients → re-wraps the secrets.d/ shards to include NEW, pushes

# ── back on NEW ────────────────────────────────────────────────────────────────
sudo git -C /opt/agentctl pull --rebase   # NEW can now decrypt the shared store

# 3. Add this host's agents ON this host (auto-pinned host: <hostname>):
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           # publish the new agent (agents add commits but doesn't push)

# ── on the gateway host (Teams only) ───────────────────────────────────────────
sudo git -C /opt/agentctl pull --rebase && sudo agentctl apply   # gateway learns the new agent's route
  • Writeback: with origin set (via --git), apply auto pull --rebase + pushes. It refuses to push a tree with merge-conflict markers (resolve with git checkout -- <file>, then re-apply).
  • Secrets: the per-owner secrets.d/*.sops.yaml shards are each encrypted to every host's age recipient; the re-wrap runs sops --config <repo>/.sops.yaml updatekeys per shard (the --config is 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 dials ws://<teams_gateway>:3978/register over the private network. Keep that port off the public IP (NSG/firewall); the public proxy fronts only /api/messages. Multi-host works for claude agents 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
The verb routes through agentd, so the admin agent can run it as itself (no sudo); the ledger commit + push then happens in agentd's git credential context, like every other ledger mutation. It rewrites the hosts: registry key, every agent's host: 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