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

What the flow guarantees by design
One client
fleet-acme-1 · mini-a
Claude
acme's login
GitHub
acme's login
Source
~/Code/acme
State
named volume
Accesstailnet
ModelMini's network
borealis · cinder separate containers
5
scenarios
1
SSH path per Mini
0
published ports
1
identity per container
5
failure layers

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.

Container anatomy · mounts and structure
flowchart TB subgraph host["Mac Mini · macOS"] direction TB SRC["~/Code/acme
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
The named volume holds the herdr state. The bind mounts hold the client state, the source, and the shared folders. The secrets mount is read-only and reaches one container.
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
1One 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.
2The 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.
3You 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.
4The 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.
5The shared toolbox mounts from ~/fleet/shared-tools in read-only mode. Your common scripts and CLIs stay available to every agent.
6The 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.
7The 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.
8Each 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.
9Orca 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.
10The 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.

Connect path · device to agent
sequenceDiagram autonumber actor U as Operator participant TS as Tailscale tailnet participant S as Mini sshd participant D as Docker participant H as herdr server participant A as Named agent U->>TS: ssh -t mini-a TS->>S: WireGuard path, direct or DERP Note over TS,S: Tailnet identity and the ACL authorize the attach S->>D: docker exec -it fleet-acme-1 herdr D->>H: attach over the local socket H->>A: attach the pane for acme A-->>U: pane output U->>A: keystrokes and the prompt Note over H,A: The agent never owns the terminal
The SSH client is a viewer of the herdr server, not the owner of the agent. That is why closing the connection does not stop the work.
Text version of this diagram
  1. The operator runs ssh -t mini-a from a laptop or a phone.
  2. Tailscale resolves the MagicDNS name and authenticates the device with the tailnet identity and the ACL.
  3. WireGuard carries the session, directly when possible and over DERP otherwise.
  4. The Mini's sshd runs docker exec -it fleet-acme-1 herdr.
  5. herdr attaches the operator to the pane of the named agent over its local socket.
  6. The agent writes to the pane, and the pane carries the output back to the operator.
#What happens
1You 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.
2Both 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.
3On 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.
4The 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.

Boot path · launchd to agent
flowchart TB BOOT["Mac Mini powers on
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
launchd starts Colima before any login. The Docker daemon and compose follow. herdr restores the workspace and starts the named agents. tailscaled comes up in parallel and carries only the access path.
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.sh as the operator. That wrapper runs colima start --foreground with --vm-type vz and --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
1launchd 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.
2Colima 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.
3The Docker daemon comes up inside the VM. compose starts every client that is not stopped, because each service sets restart: unless-stopped.
4tini 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.
5tailscaled 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.
6You 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
1A Wi-Fi blip drops the direct path between your device and Mini A during a session. To the client, the Mini simply stops responding.
2WireGuard 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.
3The 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.
4The 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.

Session persistence · drop, re-attach, restart
flowchart TB subgraph live["Live session"] A1["Agent running
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
A dropped connection detaches the client. A restart or a full recreate restores the layout with no client attached. Conversation resume is designed but unproven.
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
1Your 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.
2You attach with docker exec -it fleet-acme-1 herdr agent attach acme. The server owns the terminal, and the agent only talks to it.
3The 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.
4The agent keeps running with zero clients attached. The long tasks do not interrupt and do not restart.
5You re-attach from any client, including the phone. You get the same pane and the same scrollback, with the run still in progress.
6If 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.
7Five 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.

TrafficPathDepends 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.

container side · herdr owns the agent
# 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
client side · attach, detach, re-attach
# 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
mounts and per-client login · the isolation contract
# 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
store the two tokens, then prove the identity
# 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.

RequirementHow 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.
D6

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.

your call
D7

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.

your call
D9

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.

your call
Risk

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.

accepted

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 was sleep infinity. Task T3 now verifies the volume persistence only. The port check is retired with the port scheme, and herdr status server plus 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.