Companion to the build spec · five scenarios
Fleet Flows, step by step
This page follows one request through the whole fleet. It shows how the request travels, how it survives a dropped connection, and how it survives a reboot. It also shows what lives inside one container, and the exact configuration that makes it work.
5 scenarios herdr owns the PTY No public port
- Claude
- acme's login
- GitHub
- acme's login
- Source
- ~/Code/acme
- State
- named volume
Scenario 1 · anatomy setup
Inside one container.
This is the shape of the whole product. One Mac Mini runs Colima, which serves the Docker daemon. Each client identity gets one container. A herdr server inside it owns the agent panes. Everything shared or physical mounts in from the Mac.
One container per client gives you the isolation you asked for. Each container holds one Claude login and one gh login. herdr keeps the agent alive, and the shared skills and your real source folders mount in from the Mac.
read-write source"] STATE["~/fleet/state/acme
claude + gh"] ID["~/fleet/identities/acme
PAT + token"] SKILL["~/fleet/shared-skills
read-only"] TOOLS["~/fleet/shared-tools
read-only"] end subgraph container["Container · fleet-acme-1"] direction TB HERDR["herdr server
main child of tini"] AGENTS["Named agents
acme · acme-2"] CLAUDE["Claude Code
one login"] GH["gh
one login"] end SRC --> AGENTS STATE --> CLAUDE STATE --> GH ID --> CLAUDE ID --> GH SKILL --> AGENTS TOOLS --> AGENTS HERDR --> AGENTS AGENTS --> CLAUDE AGENTS --> GH class SRC,STATE,ID,SKILL,TOOLS host class HERDR identity class AGENTS,CLAUDE,GH box
Text version of this diagram
- The Mac holds the source folder, the client state folders, the identity secrets, the shared skills, and the shared tools.
- The container runs the herdr server as the main child of tini.
- The herdr server owns the named agents.
- The named agents run Claude Code and gh, each with one login.
- The source folder mounts in read-write. The state folders mount in read-write. The identity secrets, the shared skills, and the shared tools mount in read-only.
| # | What happens |
|---|---|
| 1 | One Mac Mini runs Colima, which serves the Docker daemon. Every client gets its own container, so a Claude login and a gh login never mix. |
| 2 | The per-client credentials live in ~/fleet/identities/acme/, as a GitHub PAT and a Claude token. They mount read-only into that container only, so no other client can read them. |
| 3 | You enter two secrets once, through the OliveTin buttons or credential-set.sh. The GitHub PAT goes to identities/<client>/gh-pat.txt. The output of claude setup-token goes to claude-token.txt. The entrypoint exports CLAUDE_CODE_OAUTH_TOKEN from that file. The credentials land in ~/.config/gh and ~/.claude, which are per-client folders on the Mac's disk. The herdr state goes to a per-client named volume. |
| 4 | The shared skills mount from ~/fleet/shared-skills in read-only mode. Edit once on the Mini, and every agent sees the change. No agent can corrupt it. |
| 5 | The shared toolbox mounts from ~/fleet/shared-tools in read-only mode. Your common scripts and CLIs stay available to every agent. |
| 6 | The source stays on the Mac. ~/Code/acme binds read-write at /work, so the edits land on the real disk and survive a container rebuild. Only that client's folder mounts. |
| 7 | The container reaches the internet through its own bridge network and NAT, over the Mini's own network. There is no public port. The only way in is the tailnet, over WireGuard. |
| 8 | Each agent is a named herdr pane, such as acme or acme-2, inside the same container. They share one identity, and the compose file caps the CPU and memory. |
| 9 | Orca and Hermes reach the agents over one SSH path per Mini. The tailnet connects you to the Mini's sshd, then docker exec … herdr agent attach <name> reaches the agent. You address the agent by name. |
| 10 | The state outlives everything. The Claude and GitHub state stays on the Mac's disk, and the herdr layout and agent session ids stay in a named volume. A rebuild or a reboot keeps both, and herdr restores the layout. Conversation resume is designed but unproven, because it needs an authenticated session. |
Scenario 2 · daily use
Connect in one command.
You run one command from a laptop or a phone. The tailnet resolves the Mini by its MagicDNS name and authenticates you with tailnet identity. On the Mini, the command reaches the client container and attaches the named agent.
The reply returns by the same route: herdr pane, container, Mini, tailnet, device. Every hop is stateless, so nothing needs a keepalive setting.
Text version of this diagram
- The operator runs
ssh -t mini-afrom a laptop or a phone. - Tailscale resolves the MagicDNS name and authenticates the device with the tailnet identity and the ACL.
- WireGuard carries the session, directly when possible and over DERP otherwise.
- The Mini's sshd runs
docker exec -it fleet-acme-1 herdr. - herdr attaches the operator to the pane of the named agent over its local socket.
- The agent writes to the pane, and the pane carries the output back to the operator.
| # | What happens |
|---|---|
| 1 | You run ssh -t mini-a 'docker exec -it fleet-acme-1 herdr' from the laptop or the phone. One SSH path reaches each Mini, by MagicDNS name, and tailnet identity authenticates you. |
| 2 | Both ends are WireGuard peers. The device and the Mini negotiate a direct, end-to-end encrypted path. If a direct path is impossible, a DERP relay carries the traffic. The traffic stays encrypted, and the Mini is never stored at the relay. Tailscale SSH authorizes you with the tailnet identity and the ACL, so there are no keys to distribute. |
| 3 | On the Mini, docker exec -it fleet-acme-1 herdr runs, or herdr agent attach acme runs. The name ends the chain inside the container that holds the identity. |
| 4 | The reply returns by the same route. Every hop is stateless. |
Scenario 3 · boot
The Mini comes up with nobody at the keyboard.
A reboot starts five layers in order: launchd, then Colima, then the Docker daemon, then compose, then herdr. The last layer is the agent, and herdr starts it in the restored pane. Conversation resume is designed, but still unproven.
This works with no login, because every layer is a system daemon or a userland process. Docker Desktop would break the chain at the second layer, because its engine starts only inside a GUI session.
no user logged in"] LD["launchd
system/dev.fleet.colima.plist"] COLIMA["Colima VM
vz + virtiofs, runs as operator"] DOCKER["Docker daemon
inside the VM"] COMPOSE["compose
restart: unless-stopped"] HERDR["herdr server
main child of tini"] AGENT["Named agents
claude --resume id"] TS["tailscaled
system daemon, joins tailnet"] YOU["Your laptop or phone
ssh over the tailnet"] BOOT --> LD --> COLIMA --> DOCKER --> COMPOSE --> HERDR --> AGENT BOOT --> TS TS -.->|"access path"| YOU YOU -.->|"docker exec"| HERDR class BOOT,LD never class COLIMA,DOCKER,COMPOSE box class HERDR,AGENT identity class TS,YOU host
Text version of this diagram
- The Mini powers on and no user logs in. launchd reads
system/dev.fleet.colima.plist. - launchd runs
colima-daemon.shas the operator. That wrapper runscolima start --foregroundwith--vm-type vzand--mount-type virtiofs, and then watches the VM. - The Colima VM starts, and its Docker daemon listens on a unix socket.
- compose starts each client container, because the restart policy is
unless-stopped. - herdr is the main child of tini in each container. It restores the layout from the named volume and starts each named agent.
- In parallel, tailscaled joins the tailnet as a system daemon. It is the access path only, and never carries model traffic.
| # | What happens |
|---|---|
| 1 | launchd starts dev.fleet.colima at boot. The plist sets UserName, so the daemon runs as the operator with no GUI session. The daemon runs colima-daemon.sh, which starts the VM in foreground mode and keeps watching it. KeepAlive/SuccessfulExit=false restarts the job after a crash. The wrapper is needed because the VM outlives the colima process. A bare foreground command exits at once when the VM is already up, and launchd would then stop supervising. |
| 2 | Colima starts its VM with the Apple Virtualization framework. The mount type is virtiofs, which the bind mounts need. herdr state stays on a named volume, because virtiofs is not a safe place for that state. |
| 3 | The Docker daemon comes up inside the VM. compose starts every client that is not stopped, because each service sets restart: unless-stopped. |
| 4 | tini starts as PID 1, because of init: true, and herdr is its main child. herdr reads the state volume, restores the layout, and starts each named agent in its pane. Conversation resume runs through the agent's own resume command, and stays unproven until an authenticated session tests it. |
| 5 | tailscaled joins the tailnet as a system daemon, so the Mini answers at its MagicDNS name before anyone logs in. No port is published, and nothing binds a public interface. |
| 6 | You reach the Mini with ssh mini-a 'docker exec fleet-acme-1 herdr status server'. The access path is Tailscale SSH with tailnet identity and ACLs, so there are no keys to distribute. |
First contact is the one exception
A new macOS has Remote Login off and no Tailscale, so the first run needs a local path. Enable Remote Login, or use a screen and keyboard, reach the Mini on the local network, and run fleet/mini-bootstrap.sh with FLEET_TS_AUTHKEY set. Every run after that is over the tailnet, with nobody at the machine.
Scenario 4 · recovery
The link heals itself.
A Wi-Fi blip drops the direct path between your device and Mini A. To you, the Mini stops responding. WireGuard notices the path is gone, and Tailscale starts to negotiate again.
The peers re-handshake, and the traffic flows over the same tailnet identity. Nothing has to be rebound, and there is no stale public listener to reap. There is no link to heal.
The agents never noticed
They run inside herdr on the Mini, and the tailnet was never in their data path. The link restores with no manual step. A real reboot proves this, in tasks T6 and T8.
| # | What happens |
|---|---|
| 1 | A Wi-Fi blip drops the direct path between your device and Mini A during a session. To the client, the Mini simply stops responding. |
| 2 | WireGuard notices the path is gone, and Tailscale starts to negotiate again. It usually restores a direct path, and it falls back to a DERP relay only when a direct path is impossible. |
| 3 | The peers re-handshake, and the traffic flows again over the same tailnet identity. Nothing has to be rebound, and no stale public listener has to be reaped, because there is no link to heal. |
| 4 | The link restores with no manual step. The agents never noticed the outage, because they run inside herdr on the Mini and the tailnet was never in their data path. A real reboot proves this, in tasks T6 and T8. |
Scenario 5 · sessions
The agent stays alive.
Your SSH command does not start the agent. The container's main process is the herdr server, and the entrypoint creates the workspace, a pane, and a named agent at boot.
You attach with herdr agent attach acme. The server owns the terminal, and the agent only talks to it.
herdr pane"] A2["Session state
named volume"] end subgraph drop["Connection lost"] B1["Client marked gone
no SIGHUP"] B2["Agent keeps running
zero clients attached"] end subgraph back["Re-attach and restart"] C1["Same pane, same scrollback
the run continues"] C2["Layout restored
claude --resume id"] end A1 --> B1 A2 --> B2 B1 --> C1 B2 --> C2 C2 --> A1 class A1,C1 box class A2,C2 identity class B1,B2 never
Text version of this diagram
- The agent runs in a herdr pane, and the session state lives in a named volume.
- When the connection drops, the server marks the client gone. The agent gets no SIGHUP, because it never owned the terminal.
- The agent keeps running with zero clients attached. The long tasks do not restart.
- A re-attach from any client, including the phone, shows the same pane and the same scrollback, with the run still in progress.
- After a restart or a full recreate, herdr restores the layout from the state volume. It runs each agent's own resume command, with no client attached.
| # | What happens |
|---|---|
| 1 | Your SSH command does not start the agent. The container's main process is the herdr server, and the entrypoint creates the workspace, a pane, and a named agent at boot. |
| 2 | You attach with docker exec -it fleet-acme-1 herdr agent attach acme. The server owns the terminal, and the agent only talks to it. |
| 3 | The SSH link drops. The window closes, the Wi-Fi blips, the laptop sleeps, or the tailnet reconnects. The server marks the client gone. The agent gets no SIGHUP, because it never owned the terminal. |
| 4 | The agent keeps running with zero clients attached. The long tasks do not interrupt and do not restart. |
| 5 | You re-attach from any client, including the phone. You get the same pane and the same scrollback, with the run still in progress. |
| 6 | If the container restarts, or a full recreate runs, herdr restores the layout from the state volume. It runs each agent's own resume command with claude --resume <id>. No client is attached. This is the layer tmux never had. |
| 7 | Five layers cover five failures. The server owns the PTY for a client drop. The supervisor covers an agent crash. The compose restart policy covers the death of the container. The state volume and the resume cover a restart or a recreate. Tailscale covers a dropped or roaming access path. |
Topology contract
The tailnet is the path.
Two lanes never share traffic. The Minis work on their own network, with their own IPs. The tailnet is an encrypted WireGuard overlay that connects your devices straight to the Minis. It is a path, not a proxy.
Because Tailscale is a WireGuard mesh, every device authenticates with tailnet identity and reaches the Minis directly. A DERP relay carries the traffic only when a direct path is impossible, and it stays end-to-end encrypted. The Minis answer at their MagicDNS name, and the ACLs decide who may reach them.
| Traffic | Path | Depends on Tailscale? |
|---|---|---|
| The agent work, which covers the Claude API, gh, and npm | The container, then Docker NAT, then the Mini's own network, then the internet | No. The work keeps running with the tailnet down. |
| Your attach and instruct session | The phone or laptop, then the tailnet over direct WireGuard, then the Mini's sshd, then docker exec, then the herdr pane | Yes. This is the only thing the tailnet is for. |
| The logins, the tokens, and the transcripts | The per-client folders on each Mini's disk, as bind mounts | No. The data stays local to the Mini. |
One tailnet for the whole fleet
That makes it one tailnet, not one link per Mini. Tailscale SSH means there are no keys to distribute. Tailscale carries only the attach and instruct traffic, and never the model traffic.
The exact configuration
What the files actually say.
These are the compose fragments, the attach commands, and the mount contract that make the isolation hold. Copy them as they are.
The image entrypoint is fleet/entrypoint.sh and its command is herdr server. The server is the container's main process, and it owns every pane PTY.
# services/borealis.compose.yml
services:
borealis:
image: my-claude-code:latest
init: true # tini reaps zombies, herdr is its main child
restart: unless-stopped # survives a crash and a Mini reboot
environment:
FLEET_AGENT_NAME: borealis # attach by name
FLEET_AGENT_KIND: claude
volumes:
# herdr state MUST be a named volume, not a macOS bind mount
- herdr-borealis:/home/node/.config/herdr
# get in, over the tailnet, from a laptop or a phone
ssh -t mini-a 'docker exec -it fleet-borealis-1 herdr'
# or go straight to the agent
ssh -t mini-a 'docker exec -it fleet-borealis-1 herdr agent attach borealis'
# leave without killing anything: Ctrl-b d, or just close ssh
# the long work keeps going after ssh exits,
# after Wi-Fi dies, or after the laptop sleeps
# services/acme.compose.yml
services:
acme:
image: my-claude-code:latest
init: true
restart: unless-stopped
volumes:
# herdr session state, a NAMED VOLUME. A bind mount breaks the server.
- herdr-acme:/home/node/.config/herdr
# writable, per identity, on the Mac's disk
- ~/fleet/state/acme/claude:/home/node/.claude
- ~/fleet/state/acme/gh:/home/node/.config/gh
# read-only, per identity, the tokens for THIS client
- ~/fleet/identities/acme:/secrets:ro
# read-only, shared by every agent
- ~/fleet/shared-skills/skills:/shared/skills:ro
- ~/fleet/shared-tools:/shared/tools:ro
# read-write, this client's real source on the Mac
- ~/Code/acme:/work
working_dir: /work
# 1. GitHub. The CLI path reads the token from stdin, so it never
# reaches argv or your shell history. The UI path is one button.
read -rs GH_PAT && printf '%s\n' "$GH_PAT" | ./credential-set.sh acme gh
gh auth status
# 2. Claude: run setup-token on a browser signed in to this client's
# account, then store the printed token the same way.
read -rs CLAUDE_TOKEN && printf '%s\n' "$CLAUDE_TOKEN" | ./credential-set.sh acme claude
./fleet.sh secrets acme # state and prefix only, never a value
# 3. Prove which identity you are on
git config --global user.email
# 4. As many agents as the memory allows
herdr agent start acme-2 --kind claude # acme already runs at boot
The rules that keep this safe
The credentials mount read-only, and they reach one container only. The shared skills and tools mount read-only, so no agent can corrupt them. Only the client's own source folder and that client's own state folders stay writable.
Mount ~/Code/<client>, never ~/Code and never $HOME. A broad mount breaks the isolation between clients.
On macOS, the host file I/O goes through the VM, on Docker Desktop and on Colima alike. Keep node_modules and the other hot paths in named volumes. Keep the source and the shared content on bind mounts. Put the herdr state in a named volume, because virtiofs cannot host it.
Never run long work from a bare shell
Do not run the agent from docker exec -it … bash. That shell dies with the connection. herdr replaces the old tmux and continue stack, and it also restores the layout and resumes the agent after a restart. tmux stays installed as an emergency fallback, because screen and dtatch survive a disconnect but not a restart.
Each layer covers a different failure. The server owning the PTY covers a client drop. The entrypoint supervisor covers an agent crash. The compose restart policy covers the death of the container. The state volume and the resume cover a restart or a full recreate. Tailscale covers a dropped or roaming access path, and it brings the link back on its own. If you add only a multiplexer, you move the problem and do not solve it.
Decisions and accuracy notes
What the review changed.
The requirements model was clarified on 18 October 2026. Each point maps to the design below, so you can check the mapping yourself.
The rest of this section records the open decisions that still need your call, and the four accuracy fixes the earlier review made.
| Requirement | How the design meets it |
|---|---|
| Mac Mini, then Docker, then one container per client identity | Covered. The Colima Linux VM is the runtime, and it starts at boot from a LaunchDaemon. |
| One Claude login and one GitHub login per container, per client | Covered by the per-client state folders on the Mac's disk, at ~/.claude and ~/.config/gh. A read-only secrets mount holds the tokens, and a per-client named volume holds the herdr state. The two tokens are entered once with credential-set.sh or the OliveTin buttons, and the sync runs without prompts. Use a subscription login, never an API key. |
| Open an SSH session, then run a session runtime | Covered by herdr, in tasks T5b and T10 and decision D11. The server owns the PTYs. tmux stays as an emergency fallback. |
| Run more than one Claude instance | One container holds many named herdr agents and panes, such as acme-1 through acme-N. They share one identity. The Mini's CPU and RAM set the limit, so cap it explicitly. See decision D6. |
| Keep the skills and the memory in one place, mounted from outside | Covered by the read-only mounts. If the agents must also write the memory, decision D7 applies. |
| Shared storage for the common scripts and tools | Covered. ~/fleet/shared-tools mounts read-only everywhere. |
| Containers can reach the internet | Covered. Each client has its own bridge network. NAT provides egress, and nothing is reachable inbound. |
| Source files mount from the Mac | Covered by the per-client read-write bind mounts. The scope and the performance notes are in decision D9. |
| Scale past three identities | The identity is a name, not a port. The registry is fleet/clients.md, and adding a client adds no network change and no port. See decision D8, which is superseded, and the stack review. |
Instances per container
Use more than one named herdr agent inside a container. One container holds one identity, and one identity holds several agents. You get the most parallel agents without new logins. The credential boundary stays at the container, and every agent has a name. The ceiling is real. A Mini with 16 GB comfortably runs 3 to 5 busy Claude Code agents across its containers. It runs fewer while the npm and test suites run. Cap it with the compose CPU and memory limits, so you do not discover the limit under load.
Shared memory write policy
Keep the skills and the tools read-only. Give the memory per identity, in a writable volume. A shared team memory is optional, and it is append-only by convention. One writable shared file across N agents is a race condition generator.
Host source mounts
Mount ~/Code/<client> only, never ~/Code and never $HOME. Keep node_modules and the other hot paths in named volumes, for speed. A container with a host mount can change those files, so the mount scope is the trust boundary.
Abuse checks, restated for N clients
The API traffic leaves from the Mac Mini's own IP. The tailnet carries only the attach traffic, and it does not change that. Several Claude accounts operating from the same one or two home IPs is the pattern that the account abuse systems look for. Use one human-scale workload per account, one login per container, and never share a credential across containers. Expect that ten agents hammering one account from one IP get that account flagged, however clean the architecture is.
The tailnet corollary
The tailnet carries only your attach and instruct traffic, so it needs no public port and no inbound rules. The devices reach each other by identity over WireGuard. Turning Tailscale off, or losing a relay, must never stop the agent work. It must only stop the remote access. Test that property explicitly. The acceptance checklist in the build spec covers it.
The four accuracy fixes
- The Dockerfile builds from
node:24-bookworm-slim, which matches the locked decision D4. Node 24 is the LTS. - The old verify step in task T3 used
curl 127.0.0.1:800x. It could never pass, because the container never published a port and the old runtime wassleep infinity. Task T3 now verifies the volume persistence only. The port check is retired with the port scheme, andherdr status serverplus an agent list check replaces it. - mosh does not do port forwarding. The phone plan is Termius, Blink, or the Tailscale app.
- The old access path needed keepalive tuning, or the silent dead link appeared. Tailscale retires that whole class of bug, because WireGuard re-handshakes and the link reconnects on its own.
Everything else holds
The read-only shared mounts, the per-client state, the tailnet access path, and the isolation guarantee all survive both reviews unchanged. The one contract that changed is the replacement of port equals identity with name equals identity.