Build spec · task plan · runtime verified

Claude Fleet on Mac Minis

Each client identity gets its own container on one of two Mac Minis. That container holds the client's Claude login and its GitHub login, and nothing else. A herdr server runs as the main process, so agents survive disconnects and restarts. You reach each agent from a laptop or a phone over a Tailscale tailnet, by name.

Spec awaiting review herdr v0.9.5 pinned and verified No published port

Fleet shape spec stage
Client identities
3 shown as an example
Hosts
2 Mac Minis
Tailnet
1
Runtime
herdr v0.9.5
State
named volume per client
Accesstailnet
ModelMini's network
spec awaiting review
3
client identities
2
Mac Minis
1
tailnet
0
published ports
12
tracked tasks

Requirements model

What the review asked for.

A Mac Mini runs Docker. Each client identity gets its own container, which holds that client's Claude login and GitHub login, and nothing else. The system mounts everything shared or physical from the Mac. The image does not bake them in.

The fleet holds N client identities. Each identity has its own Claude and GitHub credentials, and each container may run more than one agent instance. Access uses a name, not a port. One SSH hop reaches each Mini, then docker exec reaches one container.

#RequirementHow the design meets itStatus
1, 2 Mac Mini with Docker on top Colima runs a Linux VM on each Mini. Each Mini has its own compose set. The laptop uses Docker Desktop until T8. covered
3 One Claude login and one GitHub login per container Each client binds its own state folders for ~/.claude and ~/.config/gh. A read-only secrets mount holds the tokens. credential-set.sh writes each token and syncs the GitHub login, and the entrypoint exports the Claude token. Use a subscription login, never an API key. Task T4b. T4b
4 Open a session and keep it alive herdr is the main child of tini in each container and owns every pane PTY. A dropped SSH session detaches instead of killing the agent. Attach with docker exec -it <container> herdr. tmux stays installed as a fallback. Tasks T5b and T10. Decisions D5 and D11. built
5 More than one Claude instance per identity, reachable from Orca or Hermes Named herdr agents inside one container share one identity. You address them by name with herdr agent attach <client>. The scriptable API is agent list --json, agent prompt, and terminal session observe. Compose caps the count. Task T9, decision D6. T9
6 Skills and memory in one place for all agents Mount ~/fleet/shared-skills read-only in every container. Decision D7 sets the memory write policy. covered
7 Shared storage for common scripts and tools Mount ~/fleet/shared-tools read-only everywhere. covered
8 Containers can reach the internet Docker NAT sends traffic out over the Mini's own IP. The tailnet never carries it, and nothing can come in. Task T3 proves this. T3
The tailnet is the access path Both Minis and your devices join one tailnet. WireGuard connects peers directly. A DERP relay carries the traffic only when a direct path fails, and the traffic stays end-to-end encrypted. The relay reads nothing. The tailnet carries attach traffic and nothing else. Tailnet identity and ACLs prove access. There is no public endpoint and no inbound port. Take a client off the tailnet and the agent work continues. Only that client's remote access stops. covered
Agents survive disconnects and restarts The herdr server owns the pane PTYs, so a client drop does not stop the agent. The entrypoint supervisor restarts a dead agent. The compose restart policy returns a dead container. herdr state on a named volume restores the layout, and it is designed to resume each conversation. Conversation resume is unproven until an authenticated session runs it. The state must not use a macOS bind mount, because virtiofs returns EINVAL. Task T10, decisions D10 and D11. built
9 Source files live on the Mac and stay editable Bind mount ~/Code/<client> to /work/<client> in read-write mode. Hot paths such as ~/.claude and node_modules stay in volumes. Task T9, decision D9. T9

What changed from the earlier draft

The fleet is no longer three identities on three ports. It holds N client identities, each with its own Claude and GitHub credentials. Each identity may run more than one agent instance. Access uses a name, because the runtime is herdr. One SSH hop reaches each Mini, then docker exec reaches one container.

This removed the per-client port layer and the per-client tunnel layer. The isolation guarantee still holds: a container sees only its own credentials and its own code. That guarantee remains an explicit acceptance criterion.

Target architecture

Four hops from device to model.

The access path and the data path stay apart. Your device reaches a Mini over the tailnet. The Mini runs one container per client. Inside the container, herdr owns the session and the identity.

Model calls, git pushes, and package installs leave through Docker NAT on the Mini's own network. The tailnet never carries them, so a tailnet outage costs you visibility and nothing else.

Target architecture · device to tailnet to Mini to data
flowchart LR subgraph devices["1 · Operator devices"] direction TB LAP["Laptop
Orca · Hermes"] PHONE["Phone
Tailscale · Termius"] end subgraph tailnet["2 · Access plane · Tailscale tailnet"] direction TB WG["WireGuard
direct peer-to-peer"] MD["MagicDNS
mini-a · mini-b"] ACL["Identity + ACLs
no public port"] DERP["DERP relay
fallback only"] end subgraph mini["3 · Mac Mini"] direction TB SSH["ssh :22
tailnet only"] HERDR["herdr server
main child of tini"] AGENT["Named agent
one identity"] end subgraph data["4 · Data plane · Mini's own network"] direction TB NAT["Docker NAT
Mini's own IP"] API["api.anthropic.com"] GH["github.com"] NPM["npm registry"] end LAP --> WG PHONE --> WG WG --> MD WG -. fallback .-> DERP MD --> SSH DERP -.-> SSH ACL -.-> WG SSH --> HERDR HERDR --> AGENT AGENT --> NAT NAT --> API NAT --> GH NAT --> NPM class LAP,PHONE device class WG,MD,ACL access class DERP fallback class SSH host class HERDR,AGENT identity class NAT,API,GH,NPM data
The access plane ends at the herdr server. Model, git, and package traffic leaves through Docker NAT over the Mini's own network.
Text version of this diagram
  • The laptop and the phone join the tailnet.
  • WireGuard connects peers directly. DERP carries the traffic only when no direct path exists.
  • MagicDNS resolves the Mini name. Tailnet identity and ACLs authorize the connection.
  • Tailscale SSH reaches the Mini on port 22.
  • The Mini runs the herdr server as the main child of tini in the client container.
  • The herdr server owns the named agent that holds the client identity.
  • Docker NAT sends model, git, and package traffic out over the Mini's own IP.
FromToHowCarries
Laptop or phone, running Orca or Hermes The sshd of the Mini ssh -t mini-a uses a MagicDNS name One SSH path per Mini. This is the access path.
The sshd of the Mini The herdr server in the container docker exec … herdr The attach to a named agent. This is the access path.
The container Claude, gh, and npm herdr panes, each with one Claude login and one gh login The agent work. The name is the identity.
The container Anthropic, GitHub, and npm Docker NAT over the Mini's own network Model, git, and package traffic. The tailnet never carries it.

Phase map

Five phases, in order.

Each phase unlocks the next. The runtime phase is done. The host phase comes next, because every later task depends on a running container.

The phase map shows what is built, what is next, and what still waits. The task list in the next section gives the detail for each item.

Phase 0 · Image

The base image uses node:24-bookworm-slim, pinned by digest. It adds claude-code at a pinned version, jq, gh, sudo, and herdr, which is pinned and sha256 verified. The Dockerfile lives in the repo.

done

Phase 1 · Hosts

Compose files per Mini. Build the image once and load it on each Mini. Create the per-client state folders and the named herdr volume.

built · T1 to T3 on the laptop

Phase 2 · Runtime

herdr owns the PTYs and supervises the agent. It restores the layout from a named volume and is designed to resume each conversation. fleet.sh onboards each client.

built · T5b, T10

Phase 3 · Identity

Per-client logins, a PAT and a token. Mount the shared skills, tools, and source folders. Set the instance caps. Keep the client registry.

later · T4 to T9

Phase 4 · Access

Bootstrap the Mini and join the tailnet. Set the SSH config. Run the acceptance pass.

later · T6 to T8

Task breakdown

Twelve tasks, each with a verify step.

Every task names its owner machine, its exact steps, and the check that proves it works. Nothing ships without its verify step going green.

Tasks marked next can start once the spec is approved. Tasks marked later wait for the host phase. Tasks marked built have already run against a real container.

Compose files per Mini

Give each client its own file at fleet/clients/<client>.compose.yml. The file renders from fleet/client.compose.template.yml through ./fleet.sh new <client>. Each client gets its own container, its own bind mounts for ~/.claude and ~/.config/gh, and its own named herdr volume. A macOS bind mount breaks the herdr server, so the state must use a named volume. The secrets and shared mounts stay read-only.

fleet.sh sets COMPOSE_FILE from a shell glob, because Compose v2 refuses globs itself.

where · each Mini and the laptop, repo under fleet/clients/ built · laptop

Verify: docker compose config passes. ./fleet.sh up <client> starts one client and leaves every other container ID unchanged. Measured on the laptop: dev-a kept id 59dbfb73 and its start time when dev-b started. ./fleet.sh render re-renders an existing client after a template change, because new refuses it.

Depends on: nothing.

Build the image once, load it on both Minis

Build my-claude-code:latest once on the laptop from the repo Dockerfile for ARM64. Confirm claude --version, gh --version, and sudo -n true. Then docker save | ssh <mini> docker load to each Mini, so both run identical bits. A local build alone does not reach another Docker daemon. If a Mini is Intel, build there instead, because the image is architecture specific.

where · laptop, then both Minis built · laptop

Verify: the build log is green. The test container starts and the three version checks print. Measured: claude 2.1.310, gh 2.23.0, herdr 0.9.5, sudo ok. The image entrypoint now matches the repo, so the old T10 proof is no longer stale.

Depends on: T1.

First boot and volume check

Run docker compose up -d on both Minis. Confirm each client container stays up and keeps its data across restarts. The main process is now the herdr server, so herdr status server answers from inside and a workspace exists. There is still no published port, so the old curl check against port 800x is retired.

where · laptop, then both Minis built · laptop

Verify: the containers stay up through docker compose restart. docker volume ls shows each herdr-<client> volume, and data written inside a container survives the restart. Measured on the laptop: both containers restarted with the same ids. Both fleet_herdr volumes existed, and a marker on the bind mount and on the volume survived. A full recreate also restored the workspace and the named agent. Conversation resume is still unproven, because it needs an authenticated session.

Depends on: T1 and T2.

One-time identity logins

Enter each client's two secrets once, with credential-set.sh or the OliveTin buttons. It checks the token shape, writes it mode 600, and syncs. For GitHub it runs gh auth login --with-token and gh auth setup-git inside the container. For Claude it restarts the container, so the entrypoint exports CLAUDE_CODE_OAUTH_TOKEN from /secrets/claude-token.txt. Generate that token with claude setup-token on a browser signed in to the client's account. Then set git config user.name and user.email. Git identity must land on the Mac disk. The template mounts state/<client>/git at /home/node/.config/git and sets GIT_CONFIG_GLOBAL, because gh auth setup-git writes its credential helper into that file. A single-file mount fails with EBUSY, because git renames over the target.

where · inside each client container next

Verify: gh auth status and git config user.email show the right account per container, before and after a recreate. The mismatch table is empty.

Depends on: T3.

Shared skills, memory, and MCP mounts

Choose the sync method for the shared folder. Use a git repo or rsync between the Minis. Mount it read-only. Restore the old symlink path inside the containers if any config expects it.

where · both Minis and inside the containers later

Verify: a test skill file in the share appears at /shared/skills in every container. A container cannot write to it.

Depends on: T3. Decision D3 blocks the exact method.

Bootstrap the Mini and join the tailnet

Run fleet/mini-bootstrap.sh on each Mini. It installs Homebrew, git, Colima, the docker CLI, and Tailscale. It joins the tailnet with the tag tag:mini. Enable Tailscale SSH with tailscale up --ssh, so the sshd authenticates with tailnet identity and ACLs. There are no SSH keys to distribute. Set FLEET_TS_AUTHKEY to a pre-approved tagged auth key, so the join needs no browser and nobody at the keyboard. Set FLEET_TS_HOSTNAME to name the MagicDNS name, for example mini-a. In the ACL policy, allow your devices to reach tag:mini on SSH only. Disable key expiry on the Minis, so an unattended box never drops off the tailnet. Installation writes a LaunchDaemon that starts Colima at boot, so no user login is needed. There is no jump box, no autossh, and no published port.

First contact is the one exception. A new macOS has Remote Login off and no Tailscale. Enable Remote Login on the Mini, or use a screen and keyboard, then reach it on the local network and run this script. That is the only time the local network path matters.

where · both Minis and the tailnet ACL policy D2 locked later

Verify: ssh mini-a 'docker exec fleet-acme-1 herdr status server' answers over the tailnet. Both Minis show a direct path in tailscale status. Take a client off the tailnet. The agents keep working and the commits keep landing. Rejoin, and the access returns with no action on the Mini.

Depends on: T3.

Client access from the laptop and the phone

Add an SSH config alias per Mini on the laptop. Use Host mini-a, HostName mini-a, and User <you>. Attach with ssh -t mini-a 'docker exec -it fleet-acme-1 herdr' or with herdr agent attach acme. Tailscale SSH authenticates with tailnet identity, so there are no SSH keys to copy. Point Orca and Hermes the same way, with herdr machine add mini-a. On the phone, install the Tailscale app, join the tailnet, and use any SSH client such as Blink or Termius against mini-a. There is no jump host.

where · laptop and phone later

Verify: each attach lands in the expected container. Send a probe prompt and confirm which identity answers.

Depends on: T4 and T6.

Acceptance run and sign-off

Run the end-to-end pass. Reboot both Minis. Confirm each Mini rejoins the tailnet and each container comes back on its own. Run one real task per identity and confirm the commit author matches the pinned GitHub account. Kill an SSH client mid-task, so the agent keeps running in its herdr pane. Then run docker compose up -d --force-recreate on one client, and confirm herdr restores the layout. Confirm conversation resume separately, with an authenticated session that shows the transcript.

where · everything later

Verify: the acceptance checklist in the acceptance section is fully green. A colleague confirms that no logout shuffle remains for a full workday.

Depends on: T1 to T7, including T5b and T10.

Sessions that survive a dropped connection

Never run the agent from a bare docker exec -it … bash. That shell and the agent die together when SSH closes. The herdr headless server runs as the main process with CMD ["herdr","server"], and it owns every pane PTY. A disconnect is only a client leaving. The entrypoint waits for the socket. It runs herdr integration install claude, creates the workspace and pane, and starts the named agent with herdr agent start <client> --kind claude. A light supervisor runs alongside it. The supervisor acts only when herdr agent list shows no agent. Compose keeps init: true and restart: unless-stopped. You do not need an agent command loop, because herdr resumes the agent itself. tmux stays installed as a fallback, not as the runtime.

built where · Dockerfile, fleet/herdr.toml, fleet/entrypoint.sh

Verify: start a long task, kill the SSH client with SIGKILL on the pty, then reconnect and attach. The pane keeps ticking and nothing restarts. Run docker compose restart, then a full recreate. The layout returns and each agent resumes its own conversation with no client attached.

Depends on: T3. This must be in place before the reboot acceptance run in T8.

Per-client credential onboarding

Create one secrets directory per client on the Mini, at ~/fleet/identities/<client>/. Put a gh PAT there, scoped to that client's GitHub account or orgs. Put the Claude token there too. Mount the directory read-only into that container only. Then run ./credential-set.sh <client> gh and ./credential-set.sh <client> claude, or press the two OliveTin buttons. Both check the token shape, write the file mode 600, and sync it to the container. Use subscription auth, never ANTHROPIC_API_KEY, because an API key switches that container to metered API billing. Confirm with gh auth status and git config user.email. Keep the identities directory out of every shared mount and out of git.

next where · each Mini and each client container

Verify: for every client container, gh auth status shows that client's account, git config user.email matches, and the container cannot read another client's secrets directory.

Depends on: T3 and T4. This task makes the one-time token entry repeatable and scriptable.

Host mounts, scaling, and the client registry

Mount the client's real source in read-write mode, from ~/Code/<client> to /work. Mount the shared toolbox read-only, from ~/fleet/shared-tools to /shared/tools. Decide the agent instance count for each container. Cap it with the compose CPU and memory limits. Keep the registry at fleet/clients.md as the one place that lists the client, the Mini, the source folder, and the agent name. There is no port to record.

where · each Mini, the compose files, the registry later

Verify: an edit made by an agent inside /work appears on the Mac at once and survives docker compose down and up. A container cannot see another client's mounted folder. N agents run at once within the CPU and RAM cap. herdr agent list shows every agent by name.

Depends on: T3, T5b, and T6. Decisions D6 and D9.

herdr session runtime, state location, and restore

Make herdr the runtime and prove the hard part. herdr keeps its session state in ~/.config/herdr, in session.json, the session snapshots, and the sockets. On macOS that path must be a Docker named volume. On a virtiofs bind mount the server exits with EINVAL, because virtiofs supports neither setxattr nor O_TMPFILE. The image pre-creates the mount point and owns it with the node user. A fresh named volume comes up root-owned, and the server dies with EACCES. The build pins herdr to v0.9.5 and verifies the sha256. The setting HERDR_CONFIG_PATH=/etc/herdr/config.toml keeps the config outside the volume, so the mount cannot shadow it.

built and verified where · Dockerfile, fleet/herdr.toml, fleet/entrypoint.sh

Verify, all against a real container: herdr config check reports config: ok, and herdr status server answers. tini is PID 1, and herdr is its main child. The workspace and the named agent appear at boot, and the agent reaches idle. A docker restart and a full recreate both restore the pane, so the layout persists while the terminal id changes. The supervisor restarts a dead agent. docker stop saves the session gracefully, because SIGTERM saves it. Conversation resume stays unproven until an authenticated session runs it.

Depends on: T3. This supersedes the earlier tmux design. See D5, D10, and D11.

Attach map

The identity contract.

There is no port per client any more. One SSH path reaches each Mini. Then a container name. Then an agent name. The registry records the mapping, and ./fleet.sh attach <client> prints the exact commands.

The name is the identity. The container name survives a rebuild, and the agent name survives a recreate. The name you attached to answers the question. If the wrong GitHub user commits, you attached to the wrong container. Check fleet/clients.md.

ClientContainerMiniClaude and GitHubAttachStatus
acme fleet-acme-1 Mini A acme's Claude and acme's GitHub herdr agent attach acme planned
borealis fleet-borealis-1 Mini A borealis's Claude and GitHub herdr agent attach borealis planned
cinder fleet-cinder-1 Mini B cinder's Claude and GitHub herdr agent attach cinder planned
... ... ... ... one row per client registry

A name survives a rebuild. A port never did.

The container name is stable across rebuilds, and the agent name is stable across recreates. Re-running fleet.sh new does not break the attach path.

Open decisions

Sixteen decisions, most of them locked.

Each decision names its recommendation and the tasks it blocks. The review confirms or corrects the open ones, then the work continues at T4b.

D2, D4, D5, D7, D9, D10, D11, D12, D13, D14, D15, and D16 are decided. D1 and D3 still need a trial. D8 is superseded.

IDDecisionRecommendationBlocks
D1 Session owner. What do Orca and Hermes connect to? Decided: herdr. herdr owns the session per container, and there is no custom listener to build. Orca and Hermes are optional front-ends. Orca runs orca serve --port 67xx --pairing-address <mini> per container or per Mini. The laptop pairs with an access link, and the phone pairs with a QR code. The Orca docs bless SSH forwarding as the transport, and the tailnet carries that path. Orca adds server-side accounts with orca account add --agent claude, which fits the per-container identity model. Hermes is a CLI and TUI with local, docker, and ssh terminal backends. It also reaches Telegram, Discord, Slack, and WhatsApp. Run it inside each container. Drive it from the phone over chat, or over the SSH TUI from the laptop. herdr stays the session keeper underneath both. One trial remains: run orca serve end to end over the tailnet in one container. T7
D2 Access path Decided and locked: Tailscale. One tailnet holds the client devices and both Minis. WireGuard connects peers directly, and DERP is only a fallback. Tailnet identity and ACLs control access. There is no public endpoint. The EC2 host, autossh, and launchd tunnels are removed. Headscale is optional later, if you want to self-host the coordination server. T6, T7
D3 Shared skills sync between the Minis Use a private git repo. It gives you versioning and an audit trail. T5
D4 Container image base Decided: node:24-bookworm-slim, which is the current Active LTS. Rebuild and re-verify herdr on 24 in T2. T2
D5 Session runtime that keeps agents alive across disconnects Resolved: herdr, as D11 describes. tmux stays installed as an emergency fallback, and it no longer starts. herdr also restores the layout and resumes conversations after a restart. tmux never did that. T5b, T8, T10
D6 More than one agent instance. One container, or one container per instance? Run more than one herdr agent or pane in one container, so they share one identity. Cap them with the compose CPU and RAM limits. herdr makes this a pane count, not a port count. T9
D7 Shared memory. Read-only, or writable? Decided: skills and tools stay read-only. Each client gets a writable /memory bind mount, at state/<client>/memory, which fleet.sh new creates. Shared team memory is append-only by convention only. T5, T5b, T9
D8 Endpoint scheme past three clients Superseded. Names replaced ports. The registry at fleet/clients.md holds the mapping. One SSH path reaches each Mini, and you attach by container and agent name. Keep a port only for a tool that cannot speak SSH. T9, T6, T7
D9 Host source mounts. Scope and performance Mount ~/Code/<client> only, never the whole home directory. Keep the hot paths in named volumes. T9
D10 Where herdr state lives Decided: a Docker named volume at /home/node/.config/herdr. A macOS bind mount makes the server exit with EINVAL, because virtiofs lacks setxattr and O_TMPFILE. The image pre-creates the directory and owns it with the node user. The source folder, ~/.claude, and ~/.config/gh stay bind mounts. T10
D11 Container runtime. Which process owns the panes? Decided: tini is PID 1 because of init: true. The herdr server is its main child, entered through fleet/entrypoint.sh and pinned to v0.9.5 with a sha256 check. The setting HERDR_CONFIG_PATH=/etc/herdr/config.toml keeps the state volume from shadowing the config. tmux stays installed but does not start. T5b, T10
D12 Where herdr runs, and what a down -v costs Decided: herdr runs per container, never per Mini, and never off the box. The Claude integration hook reports the session id over the herdr local socket, and that is what makes the resume exact. The corollary matters: docker compose down -v deletes the herdr volume and loses the session ids. The transcript still survives in the ~/.claude bind mount, so a manual resume still works. T10
D13 Which container runtime runs on a Mini Decided: Colima, not Docker Desktop. The fleet must return after a reboot with nobody at the machine. Docker Desktop runs its engine inside the GUI login session, so a LaunchDaemon cannot start it. Colima is a userland process. A LaunchDaemon runs fleet/colima-daemon.sh at boot as the operator, with launchd's UserName key. That wrapper holds the VM open and restarts it if it stops. The laptop keeps Docker Desktop until T8, then both sides align. Verified on the laptop with a user LaunchAgent. The system LaunchDaemon is not yet installed on a Mini. T6, T8
D14 How the image reaches each Mini Decided: build once, then docker save | ssh <mini> docker load. One Dockerfile, one tag, identical bits. A local build stays in the local daemon. Build on a Mini only if that Mini is a different architecture. T2, T6
D15 How a non-technical operator runs the runbook Decided: OliveTin as a thin button layer over the same scripts. It is optional, binds to 127.0.0.1, and is reached through an SSH tunnel. The scripts remain the source of truth. Two credential actions accept a secret: they use exec:, so no shell parses it, and the log records it as <redacted>. Every other action takes no secret. T8
D16 Where Nix fits Decided: dev shell and operator shell only. The flake pins the tools a person types into a terminal. It does not manage the container runtime or the tailscaled daemon, because a store path inside a launchd plist breaks on the next garbage collection. T6

Abuse risk

Three Claude accounts that operate from two home IP addresses can trip the rate and abuse checks. Keep one human-scale workload per account. Never share one login across containers.

Acceptance checklist

The checks that end the debate.

Every line below must go green. A green line means the logout shuffle is dead. You pick a name, you connect, and you instruct.

Some lines have run against a real container already. The rest still wait on the host acceptance run in T8.

  • Both Minis reboot, and every client container returns without a manual step. herdr restores each layout.
  • A long task survives an SSH or tailnet drop. A re-attach finds the same agent run, never a restart.
  • A restart and a full recreate restore the pane. Conversation resume is a separate check, once an authenticated session is available.
  • gh auth status reports the right account in every container after a reboot.
  • The laptop and the phone each reach every client over the tailnet by name.
  • One real task per client, and the commit authors match the pinned GitHub accounts.
  • A shared skill edit appears in all containers, and no container can write to the share.
  • Each container sees its own writable /memory. No other client's memory is visible.
  • Each client container reads only its own /secrets and sees only its own source folder.
  • An edit inside a mounted source folder lands on the Mac's disk and survives a container rebuild.
  • Containers reach npm, gh, and api.anthropic.com over the Mini's own network. Nothing is reachable inbound.
  • Take a client off the tailnet. The running agents keep working and keep committing. Only remote access is lost.
  • More than one agent instance runs in one container within the CPU and RAM cap. You reach each one by name.
  • The tailnet exposes no public endpoint. Revoking a device cuts its access at once.
  • herdr state lives in a named volume, so a full recreate still restores the session.

Definition of done

The runtime contract is green and the host contract is green. Until then, the honest status is: the runtime is verified, the host rollout is pending, and the isolation hardening comes next.

Stack review

What herdr let us remove, and what it added.

Choosing herdr as the runtime did more than swap tmux for a newer tool. It removed a layer of plumbing. That layer existed only because the old runtime could not keep a process alive or be reached any other way.

The first list shows what came out. The second shows what came in. Each removed item names its replacement.

Removed

RemovedIt was doingReplaced by
tmux as the primary runtime Detach and re-attach a shell The herdr server owns the PTYs. tmux stays as an emergency fallback.
A published port per client Give Orca or Hermes a TCP endpoint SSH, then docker exec, then herdr agent attach.
EC2, autossh, and a launchd unit per Mini Publish the Mini's sshd so you can reach it from outside The Tailscale tailnet. The Minis and the devices peer directly, with no jump box.
The port registry at fleet/ports.md Treat the port as the identity fleet/clients.md. The identity is the container and agent name.
The claude --continue loop Guess the resume point after a restart resume_agents_on_restore with claude --resume <id>.
tmux-resurrect and continuum Save and restore the panes The herdr restore, which is built in.
The tmux exit-empty hack Keep tmux alive with nobody attached The herdr server is the main child process. There is nothing to keep alive.
CMD ["sleep","infinity"] Keep the container up The herdr server is the application.

Added

AddedWhy it earns its place
herdr machine add and herdr --remote mini-a One window reaches both Minis and the local machine over plain SSH, with no custom plumbing.
Named agents with herdr agent attach <client> You attach by name, and the attach survives a recreate without remembering a number.
agent prompt, terminal session observe, and agent list --json Scriptable orchestration, and the real answer to D1. An API replaces a port.
herdr integration install claude Claude Code reports its session id, so the resume after a restart is exact.
fleet/backup.sh The herdr state is a named volume now, and state worth keeping deserves a copy path.
./fleet.sh attach You stop hand-writing the SSH chain. The command prints the exact attach for each client.

Still on the table

Headscale would let you self-host the Tailscale coordination server. The Minis and the devices still speak WireGuard peer to peer either way. Claude Code Remote Control and claude agents would give the phone a native path with nothing to install on the Minis or in Orca. Both are worth a trial inside one container before you commit.

Review

Confirm or correct D1 to D4, D6, D7, and D9.

Then

Say go, and the implementation starts at T1.

Related

This spec, the fleet flows walkthrough, and the fleet folder in the repo.