Internal proposal · runtime verified · rollout pending

One identity per container.
Agents that never blink.

Today, working across clients means logging out of Claude, re-authenticating GitHub, remembering which port belonged to whom, and hoping the terminal survives. Claude Fleet gives every client its own container and its own logins. It keeps the agent alive through disconnects and restarts. It also returns after a host reboot, and that chain is measured on a laptop. You reach it by name, over a private tailnet.

No public endpoint No model traffic on the access layer herdr pinned + sha256

reference identity target state
acme
mini-a · fleet-acme-1
Claude
authenticated
GitHub
authenticated
Source
~/Code/acme
Memory
read-write · per client
accesstailnet
modelMini's network
borealis · cinder separate containers

Illustrative target state · no client onboarded yet

Decision

Approve a two-client pilot on the two existing Minis.

Cost

Existing hardware. No new cloud compute.

Gate

You onboard the first client only after all five rollout gates pass.

5
survival layers
0
public ports
1
identity per container
2
Minis · one tailnet
30s
supervisor cadence

At a glance

Isolated by construction

One container per client, holding that client's Claude login and GitHub login and nothing else. Each container mounts the secrets read-only. Source and memory are per-client.

Durable by design

herdr owns the pane PTY, not your SSH process. A restart or full recreate restores the layout and resumes the agent's own conversation with no client attached.

Private by default

Tailscale tailnet, MagicDNS names and ACLs. No jump box, no autossh, no inbound port. Model and git traffic leave over the Mini's own network.

The path, end to end

One attach. Two networks. No public door.

A device joins the tailnet, resolves a MagicDNS name, and attaches to a container that already holds the client's identity. From there, model, git and package traffic leave through the Mini's own network. The tailnet never carries it.

The only inbound path is Tailscale SSH to the Mini on :22. The design publishes no container port. It has no jump box. DERP can carry the encrypted tailnet path only when a direct peer-to-peer link fails.

End-to-end path · access lane vs data lane
  1. 1
    Device joins the tailnetThe laptop or phone resolves the Mini by MagicDNS name.
  2. 2
    Tailscale SSH reaches the MiniTailnet identity and ACLs authorize :22. Nothing is published.
  3. 3
    The container already holds the identityDocker exec attaches herdr to the named agent over its local socket.
  4. 4
    Work leaves through the MiniClaude, git and npm use Docker NAT on the Mini's own network. They never use the tailnet.

Swipe or drag to pan · Expand for full size

The access lane ends at the container's herdr socket. The data lane starts at the container's own network stack. They meet at the workload. They never meet at a proxy.
Text version of this diagram
  1. A laptop or phone joins the tailnet and resolves the Mini by MagicDNS name.
  2. Tailscale SSH reaches the Mini on port 22. Tailnet identity and ACLs authorize the attach. Nothing is published to the internet.
  3. Docker exec enters the client's container and herdr attaches the operator to the named agent over its local socket.
  4. Claude, git and package traffic leaves the container through Docker NAT on the Mini's own network. The tailnet is not a hop in that path.

The shift

The logout shuffle is an architecture problem.

Account switching is a symptom. The real problem is that the agent lives inside whatever terminal created it, and identity lives in whatever account was last logged in. Fix those two things and the daily friction disappears.

This is not a new IDE or a new agent. It is a small runtime change with a large operational effect: identity becomes a container, and the session becomes a server that outlives every connection.

Today

  • Log out of one Claude account, log into another.
  • Re-authenticate GitHub, then hope the right author commits.
  • Remember which port belonged to acme and which to borealis.
  • Keep a tmux session alive and guess-resume after a restart.
  • Reach the Mini through a jump box, a tunnel, or a public port.

With Claude Fleet

  • Attach to acme. Its container already holds acme's Claude and GitHub logins.
  • The agent runs in a herdr pane, independent of your SSH process.
  • A restart or recreate resumes the agent's own conversation, not a guessed one.
  • Identity is a name in one registry. There is no port to look up.
  • The Mini is reachable over a private tailnet, by MagicDNS name.
The agent must not live in the process that your SSH connection created.
fleet/README.md · the invariant everything else follows from

herdr's headless server is the container's main process. It owns every pane PTY, so closing the laptop is just a client leaving. Reattach later and the same process is mid-sentence.

Outcome 01

One identity, one blast radius

A compromised or confused agent sees its own credentials, its own source, and its own memory. It does not see another client's login.

Outcome 02

One name, not a port

acme is a container and an agent. The name survives a rebuild. A port number never did. The registry is the contract.

Outcome 03

One private path, not a public door

No jump box, no published port, no inbound path. Access is proven by tailnet identity and constrained by ACLs.

Architecture

Four planes. One invariant.

Devices reach a Mini over a private tailnet. The Mini runs one container per client. Inside each container, herdr owns the session and the client's identity stays put.

The key separation: the access plane carries attach and instruction traffic. The data plane carries model, git and package traffic straight out of the Mini. They are not the same path.

System topology · devices → tailnet → Minis → data
flowchart LR subgraph devices["① Operator devices"] direction TB L["Laptop
ssh · Orca / Hermes"] P["Phone
Tailscale · Termius"] end subgraph access["② Access plane · Tailscale tailnet"] direction TB TS["WireGuard
direct peer-to-peer"] MD["MagicDNS
mini-a · mini-b"] ACL["Identity + ACLs
no public port"] DERP["DERP relay
fallback only"] end subgraph minia["③ Mac Mini A · mini-a"] direction TB SA["ssh :22
tailnet only"] CA["fleet-acme-1
herdr · Claude + gh"] CB["fleet-borealis-1
herdr · Claude + gh"] end subgraph minib["④ Mac Mini B · mini-b"] direction TB SB["ssh :22
tailnet only"] CC["fleet-cinder-1
herdr · Claude + gh"] end subgraph data["⑤ 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 L --> TS P --> TS TS --> MD TS -. fallback .-> DERP MD --> SA MD --> SB DERP -.-> SA ACL -.-> TS SA --> CA SA --> CB SB --> CC CA --> NAT CC --> NAT NAT --> API NAT --> GH NAT --> NPM class L,P device class TS,MD,ACL access class DERP fallback class SA,SB host class CA,CB,CC identity class NAT,API,GH,NPM data
The access plane ends at the container's herdr server. Model, git and package traffic leaves through Docker NAT over the Mini's own network. The tailnet is not in that lane.
Text version of this diagram
  • Laptop and phone join the tailnet. MagicDNS resolves mini-a and mini-b.
  • Tailscale establishes a direct WireGuard path. DERP is an encrypted fallback only.
  • Each Mini's SSH endpoint is reachable only on the tailnet. Tailnet identity and ACLs gate the access.
  • Inside each Mini, one container per client runs a herdr server and one named agent.
  • The container reaches api.anthropic.com, github.com and npm via Docker NAT on the Mini's own network.
01 · Access

Tailscale tailnet

WireGuard, mostly direct peer-to-peer. MagicDNS names, device identity, ACLs. DERP only when a direct path is impossible. The path stays end-to-end encrypted.

no public endpoint
02 · Session

herdr server, per container

herdr is the main child of tini in each container and owns every pane PTY. The herdr server attaches named agents over a local socket. Layout and session ids live in a named volume. The config lives outside it.

survives disconnects
03 · Identity

One container per client

That client's Claude login and GitHub login, a :ro secrets mount, per-client source and memory, read-only shared skills and tools.

name = identity
04 · Data

The Mini's own network

Docker NAT sends model, git and npm traffic out over the Mini's IP. No inbound path. The access layer never carries model traffic.

never on the tailnet
LayerInstalled byWhat it provides
Mac Mini hostmini-bootstrap.sh (once)Homebrew, git, Colima with the docker CLI, and the open-source tailscale + tailscaled CLI. Installs a LaunchDaemon that starts Colima at boot. Joins the tailnet with tag:mini, runs before login, and enables Tailscale SSH.
Container imageDockerfile + entrypoint.shNode 24, git, gh, ssh, tmux fallback, Claude Code, and herdr v0.9.5 pinned with a sha256 check.
Container runtimeRendered compose + fleet.sh upPer-client mounts and env, init: true, restart: unless-stopped, CPU/RAM caps, no published ports.
AccessTailscale + ACLsMagicDNS, Tailscale SSH, device identity, revocable access, DERP fallback. No jump box or public port.
node:24bookworm-slim base
herdr 0.9.5pinned · sha256 verified
2 CPU / 6 GBper-client cap · tune per Mini
0 portspublished by design

The boundary that matters

Two lanes. The access layer never carries model traffic.

The tailnet exists to carry your attach and instruction traffic. Model calls, git pushes and package installs go out through Docker NAT on the Mini's own network. Keeping those lanes separate is what makes the design private and cheap to reason about.

A tailnet outage is an observability outage. It is not a production outage. Agents keep working and commits keep landing. You cannot watch them until the link returns.

Control lane vs data lane · where traffic goes
flowchart TB subgraph control["CONTROL LANE · carried by Tailscale"] direction LR U["Operator
laptop / phone"] --> T["Tailnet
WireGuard · DERP fallback"] T --> S["Mini SSH"] S --> H["herdr server
local socket"] H --> A["Agent pane
PTY"] end subgraph data["DATA LANE · Mini's own network"] direction LR C["Container
Claude · gh · npm"] --> NAT["Docker NAT
Mini's own IP"] --> NET["api.anthropic.com
github.com · npm"] NO["✕ Tailnet never enters this lane"] end A -. prompts and keystrokes .-> C class U,T,S,H,A control class C,NAT,NET dataNode class NO never
Instruction traffic crosses the tailnet. Model, git and package traffic never does. The container is the meeting point. It is not a proxy.
Text version of this diagram
  • Control lane: operator → tailnet → Mini SSH → herdr server → agent pane.
  • Data lane: container → Docker NAT on the Mini's own IP → Anthropic, GitHub and npm.
  • The only link between the lanes is the container itself. The tailnet is never a hop in the data lane.
TrafficPathUses the tailnet?Public inbound?
Attach, keystrokes, promptsDevice → tailnet → Mini SSH → herdrYesNo
Model callsContainer → Docker NAT → api.anthropic.comNeverNo
Git push / pullContainer → Docker NAT → GitHubNeverNo
Package installsContainer → Docker NAT → npm registryNeverNo
Secrets and credentialsMac disk → :ro mount → containerNeitherNo
BackupsMini local disk / your backup targetNeitherNo

A tailnet outage is not a work outage

Take a client off the tailnet, or pause Tailscale on the laptop: running agents keep working and keep committing. Only remote access is lost. Rejoin and access returns with no action on the Mini.

This is the acceptance test that proves the separation. It is still on the rollout checklist. It has not run end-to-end yet.

The identity contract

Attach by name. Prove the identity.

There is one SSH path per Mini, then a container name, then an agent name. No port registry, no per-client tunnel, no guessing which account answered.

fleet.sh attach <client> prints the exact commands. The registry, fleet/clients.md, records the mapping from client to Mini to source to agent.

Attach path · operator → herdr
sequenceDiagram autonumber actor U as Operator participant TS as Tailscale tailnet participant S as Mini SSH participant D as Docker participant H as herdr server participant A as Agent pane U->>TS: ssh -t mini-a TS->>S: WireGuard path (direct, else DERP) Note over TS,S: MagicDNS name · tailnet identity · ACL check S->>D: docker exec -it fleet-acme-1 herdr D->>H: attach over local socket H-->>U: pane and agent output U->>H: keystrokes / prompt H->>A: PTY input A->>A: works inside its own pane Note over S,D: No published port. No inbound path from the internet.
The SSH client is a viewer of the herdr server, not the owner of the agent. That is why closing it does not stop the work.
Text version of this diagram
  • The operator runs ssh -t mini-a. Tailscale resolves the MagicDNS name and authenticates the device.
  • The Mini's SSH endpoint runs docker exec -it fleet-acme-1 herdr.
  • herdr attaches the operator to the named agent over its local socket.
  • Keystrokes go to the agent's PTY. Output comes back through the same path. No port is published.
attach, from any tailnet device
# full workspace UI (panes, terminals, agents)
ssh -t mini-a 'docker exec -it fleet-acme-1 herdr'

# straight to the named agent
ssh -t mini-a 'docker exec -it fleet-acme-1 herdr agent attach acme'

# scriptable / from a front-end or CI
ssh mini-a 'docker exec fleet-acme-1 herdr agent list --json'
prove which identity answered
docker exec fleet-acme-1 gh auth status
docker exec fleet-acme-1 git config --global user.email
docker exec fleet-acme-1 herdr agent list

# the agent name you attached to is the identity
# if the wrong author commits, you are in the wrong container
ClientContainerMiniClaude + GitHubAttach
acmefleet-acme-1Mini Aacme's Claude + acme's GitHubherdr agent attach acme
borealisfleet-borealis-1Mini Aborealis's Claude + GitHubherdr agent attach borealis
cinderfleet-cinder-1Mini Bcinder's Claude + GitHubherdr agent attach cinder

Example registry shape. The current build onboards no clients. The first onboarding is a rollout gate.

Name = identity. A name survives a recreate. A port never did.

Container names are stable across rebuilds and agent names are stable across recreates. The name you attached to answers "Which identity am I on?". It is not a number you have to look up.

Durability

Five failures. Five independent answers.

Each layer covers a different blast radius: a dropped client, a crashed agent, a dead container, a restart or recreate, and a lost network path. They do not depend on each other.

One thing is deliberately not automatic: the SSH attach itself. Reconnecting is a re-attach, not a process restart. That distinction is the whole design.

Client disconnects built

herdr's server owns the pane PTY. Closing the laptop, killing the SSH client or losing the tailnet link is just a client leaving. The agent keeps running.

herdr server owns every pane
automaticno restart

Agent process crashes built

The entrypoint's light supervisor checks herdr agent list every 30 seconds. If no agent is present, it starts the named agent in the first pane.

supervisor · FLEET_AGENT_CHECK_SECONDS=30
automatic≤ 30 s gap

Container dies or the Mini reboots host gate

Compose uses restart: unless-stopped. The container returns once the Docker daemon is running. Colima supplies that daemon, and a LaunchDaemon starts Colima at boot with no login.

restart policy + launchd → Colima → Docker
automatic once Colima is upunattended reboot untested

Restart or full recreate verified in T10

herdr state lives on a named volume. The setting resume_agents_on_restore=true and the Claude integration hook restore the layout. They also re-run claude --resume <id> for each agent. No client is attached.

named volume + integration hook · exact session id
automaticnot a blanket --continue

Access link drops or you roam available

Tailscale re-establishes the path: direct WireGuard when possible, DERP when NAT or UDP blocks it. You then re-run the attach. The agent never noticed the outage.

direct → DERP fallback · MagicDNS
network automaticattach is manual
Retry ladder · failure → recovery
flowchart TD S(["Attach request"]) --> T{"Tailnet reachable?"} T -->|no| TR["Tailscale reconnects
direct → DERP"] TR --> T T -->|yes| SH{"SSH answers?"} SH -->|no| MI{"Mini awake?"} MI -->|no| RB["Power / autostart
bring the Mini back"] RB --> MI MI -->|yes| SH SH -->|yes| CO{"Container running?"} CO -->|no| RP["restart: unless-stopped
or fleet.sh up"] RP --> CO CO -->|yes| HS{"herdr server?"} HS -->|no| RC["Recreate container
named volume keeps state"] RC --> HS HS -->|yes| AG{"Agent present?"} AG -->|no| SV["Supervisor ≤ 30 s
starts the named agent"] SV --> AG AG -->|yes| OK(["Attached · same pane"]) AG -.->|session id missing| MR["Manual claude --resume
from the ~/.claude transcript"] class T,SH,MI,CO,HS,AG ask class TR,RB,RP,RC,SV retry class S,OK ok class MR manual
Each failure has an owner. Retry nodes are automatic except the last two. The operator or the front-end re-runs the attach. A lost session id falls back to a manual resume.
Text version of this diagram
  • Tailnet unreachable → Tailscale reconnects (direct, else DERP) → retry.
  • SSH unreachable → check whether the Mini is awake. If not, power/autostart it.
  • Container down → restart: unless-stopped or fleet.sh up.
  • herdr server down → recreate the container. The named volume keeps the state.
  • Agent absent → supervisor starts the named agent within 30 seconds.
  • Session id missing → manual claude --resume from the transcript in ~/.claude.
Disconnect · re-attach, not restart
sequenceDiagram autonumber actor U as Operator participant SSH as SSH client participant H as herdr server participant A as Agent pane U->>SSH: attached SSH->>H: interactive session H->>A: PTY Note over U,SSH: Wi-Fi drops · laptop sleeps · tailnet link lost SSH--xH: connection lost Note over H,A: server notes nobody is attached
the agent keeps running A->>A: task continues U->>SSH: re-attach SSH->>H: docker exec ... herdr agent attach acme H-->>U: same pane · mid-sentence Note over U,H: Reconnect is a re-attach, not a restart.
The agent never sees the disconnect. Re-attaching attaches a new viewer to the same PTY.
Restart · exact resume
sequenceDiagram autonumber actor U as Operator participant DC as Docker participant E as entrypoint participant H as herdr server participant V as herdr volume participant C as Claude Code U->>DC: docker restart / recreate DC->>E: start container E->>H: exec herdr server (main child of tini) H->>V: read session.json + snapshots V-->>H: layout + agent session ids H->>C: claude --resume session id Note over H,C: exact conversation
no client attached E->>H: integration install claude (idempotent) alt restore succeeds H-->>U: pane restored · agent idle else agent absent after 15 s E->>H: start named agent acme end loop supervisor · every 30 s E->>H: herdr agent list alt no agent E->>H: start named agent acme end end
Resume is exact because the Claude integration hook reports the session id over herdr's local socket. The supervisor is the fallback, not the primary path.
EventAutomatic?MechanismResidual risk
SSH / tailnet client dropsYesherdr server owns the PTYNone for the agent
Agent process crashesYes, ≤ 30 sentrypoint supervisor starts the named agentConversation recovery depends on the session id
Container process exitsYesrestart: unless-stoppedOnly after the Docker daemon is up
Container restart / recreateYesnamed volume + resume_agents_on_restoreLost only with down -v
Mini rebootsConditionallaunchd starts Colima, then Tailscale, then composeNo login needed. Measured as a VM restart on the laptop. A Mini power cut is unproven
SSH client reconnectsOperator / front-endRe-run the attach commandNo process restart. Just a new viewer
Session id missingManualclaude --resume from the transcriptTranscript survives in ~/.claude

Reboot recovery rests on the boot chain

restart: unless-stopped restarts containers once the Docker daemon is running. The Mini starts that daemon through Colima, and a LaunchDaemon starts Colima at boot as the operator. No user login is needed. The chain is launchd, then Colima, then Docker, then compose, then herdr.

Measured on the laptop: stop the Colima VM, then start it again. The same container id returns with its bind and volume data. The layout is restored, and the supervisor brings the agent back within one check interval. Two gates remain: install the system LaunchDaemon on a Mini, and cut the power with nobody logged in.

Lifecycle

The container has one job: keep the session.

Everything else is a state transition. The state that matters lives on a named volume. It is herdr's layout and each agent's session id. The volume survives a restart and a recreate.

The transcript is separate, on the Mac's disk. That separation is why losing the herdr volume costs convenience, not the conversation itself.

Container and session states
stateDiagram-v2 [*] --> Provisioned: new Provisioned --> Starting: up Starting --> Running: boot Running --> Stopped: down Stopped --> Starting: up Running --> Lost: down -v Lost --> Provisioned: re-onboard note right of Running disconnect → re-attach crash → auto-start restart → resume end note
Normal operation never leaves Running. A disconnect, a crash and a restart are internal recoveries that return to the same agent. Only down -v moves the session id out of reach.
Text version of this diagram
  • fleet.sh new → provisioned. fleet.sh up → starting → running.
  • Inside running, a disconnect re-attaches. A crash auto-starts the named agent. A restart or a recreate resumes the exact conversation.
  • fleet.sh down → stopped. up returns to running.
  • docker compose down -v → lost. Re-onboard and log in again. The transcript survives in ~/.claude for a manual resume.
WhatWhere it livesRestartRecreatedown -v
herdr layout + session idsNamed volume fleet_herdr-<client>SurvivesSurvivesLost
Claude transcript~/.claude bind mount on the MacSurvivesSurvivesSurvives
GitHub auth~/.config/gh bind mountSurvivesSurvivesSurvives
Client source~/Code/<client> bind mountSurvivesSurvivesSurvives
Secretsidentities/<client> · read-onlySurvivesSurvivesSurvives
Shared skills / toolsshared-skills, shared-tools · read-onlySurvivesSurvivesSurvives
Per-client memorystate/<client>/memory · writableSurvivesSurvivesSurvives

docker compose down -v is the one footgun

It deletes the named volume, so herdr loses the layout and every agent's session id. There is no automatic resume on the next start. The transcript still survives in ~/.claude, so a manual claude --resume is possible.

Plain down (no -v) is safe. A guard in fleet.sh is a hardening item, not built yet.

Backup exists, but needs a name fix first

backup.sh copies the herdr volume and the client's state. It currently references herdr-<client>, while Compose prefixes the project name and creates fleet_herdr-<client>.

That mismatch can produce a silent empty backup. Fix the volume name and test a restore before relying on it.

Isolation and security

No public surface. Credentials scoped per container.

Tailnet identity and ACLs gate the access. The runtime mounts the credentials read-only into exactly one container. The boundary that does the real work is the mount, not a firewall rule.

This section states plainly what the runtime enforces today. It also states what is still hardening. A leadership page that hides the gap is not useful.

Zero public surface · inbound paths
flowchart TB X["✕ public internet · no inbound route"]:::blocked X ~~~ D D["Operator device"] --> T["Tailnet
identity + ACLs"] T --> S["Mini SSH :22"] S --> C["Container
herdr"] C --> K["/secrets :ro
this client only"] C --> W["/work rw
this client only"] C --> M["/memory rw
this client only"] class X blocked class D,T,S edge class C,K,W,M box
There is no edge from the internet to the Mini. The only way in is a tailnet device that the ACLs allow.

What is enforced today

Per-container Claude and GitHub state. A read-only secrets mount for that client only. Per-client source and memory. Shared skills and tools read-only in every container. No published ports, no inbound route.

What is still hardening

All client containers currently share the Compose default network. The container user has passwordless sudo. The page describes the Tailscale ACL policy. The repo does not commit it as an artifact. These are the next security tasks.

Threat model in one paragraph

A compromised agent gets its own credentials, its own source, its own memory, and sudo inside its container. It does not get another client's secrets. Today it shares a Docker network with the other containers. Per-client networks are the fix.

Container pathSource on the MacModeBoundary
/home/node/.claudestate/<client>/clauderwThat client's transcript and config
/home/node/.config/ghstate/<client>/ghrwThat client's GitHub auth
/secretsidentities/<client>roThat client's PAT, not in git, not in a shared mount
/work~/Code/<client>rwThat client's source on real disk
/memorystate/<client>/memoryrwThat client's memory only
/shared/skillsshared-skills/skillsroEvery container. Only the operator edits the Mac side
/shared/toolsshared-toolsroEvery container. Read-only by design
/home/node/.config/herdrnamed volumerwThat client's layout and session ids
target tailnet ACL shape, not committed yet
{
  "groups": {
    "group:operators": ["you@example.com"]
  },
  "tagOwners": {
    "tag:mini": ["group:operators"]
  },
  "acls": [
    {
      "action": "accept",
      "src": ["group:operators"],
      "dst": ["tag:mini:22"]
    }
  ],
  "ssh": [
    {
      "action": "accept",
      "src": ["group:operators"],
      "dst": ["tag:mini"],
      "users": ["autogroup:nonroot"]
    }
  ]
}

Identity, not keys

Tailscale SSH authenticates the device, and the ACL authorizes the connection. There are no SSH keys to copy or rotate on the laptop or phone, and revoking a device cuts access instantly.

Disable key expiry on the Minis so an always-on box never drops off the tailnet. The ACL example above is a target. The repo does not yet contain the live policy.

Residential-IP concentration is an accepted operational risk

All clients egress from the same one or two home IPs. Keep one human-scale login per account and never share a Claude login across containers. This is a policy control, not something the runtime can enforce.

Rollout and operations

Two scripts. Two layers. No overlap.

The host script never touches containers. The image never touches the host. One bootstrap per Mini, one onboarding per client, then the same attach command from laptop or phone.

The runtime is proven in a container. What remains is the host rollout and the acceptance run. This work turns a good design into evidence.

Client onboarding workflow
flowchart LR A["1 · prepare Mini
Colima · Tailscale
LaunchDaemon"] --> B["2 · join tailnet
tag:mini · SSH"] B --> C["3 · onboard client
fleet.sh new
secrets · up"] C --> D["4 · enter secrets
credential-set.sh
or OliveTin"] D --> E["5 · verify + attach
auth · agent list
herdr attach"] class A,B,C,D,E s
Steps 1 and 2 run once per Mini. Steps 3, 4 and 5 run once per client. Nothing in the client path restarts another client. Each client is its own Compose service with its own file.
Text version of this diagram
  • 1 · prepare Mini. Run mini-bootstrap.sh. It installs Homebrew, git, Colima, the docker CLI and Tailscale, then loads a LaunchDaemon that starts Colima at boot. No GUI login is needed.
  • 2 · join tailnet , join with tag:mini, enable Tailscale SSH, disable key expiry. Set FLEET_TS_AUTHKEY so the join needs no browser.
  • 3 · onboard client. Run fleet.sh new. It creates the compose file, folders and registry row. Paste the GitHub PAT and the Claude token. Run fleet.sh up. It starts only that client.
  • 4 · enter secrets. Use the OliveTin buttons, or run credential-set.sh with the token on stdin. It writes each file mode 600 and syncs. GitHub runs gh auth login --with-token and gh auth setup-git. Claude restarts the container and the entrypoint exports the token.
  • 5 · verify + attach. Check gh auth status and herdr agent list. Then attach by name from laptop or phone.

Prepare each Mini

Run mini-bootstrap.sh with FLEET_TS_AUTHKEY set. It installs Colima, the docker CLI, Tailscale and a LaunchDaemon, so the runtime starts at boot. No GUI login and no physical presence are needed after first contact.

Build the image once, load it twice

Build my-claude-code:latest once on the laptop. Confirm Claude Code, gh, git and the pinned herdr version. Then docker save | ssh <mini> docker load to each Mini, so both run identical bits.

Onboard the first client

fleet.sh new, paste secrets, fleet.sh up. Log in once, then verify which identity answers.

Prove the contract

Kill the SSH client mid-task, re-attach, then restart and fully recreate the container. The same conversation must come back.

Add the second Mini

Bootstrap it, onboard another identity, then run the reboot and tailnet-only acceptance checks. Only then call it production.

day-2 operations
./fleet.sh new acme          # add one client, touch nothing else
./fleet.sh up acme           # start just that container
./fleet.sh ps                # what is running
./fleet.sh logs acme         # follow one client
./fleet.sh restart acme      # restart one client
./fleet.sh down acme         # stop + remove, keep state
./fleet.sh attach acme       # print the exact attach commands
./fleet.sh config            # validate every compose file

docker exec fleet-acme-1 herdr status server
docker exec fleet-acme-1 herdr agent list
docker exec fleet-acme-1 gh auth status

Capacity is a measured number, not a guess

The compose template caps each client at 2 CPUs and 6 GB. That cap is explicit. You must measure the real density on a Mini during the acceptance run. Agents are mostly idle. Builds are not.

Four things are still manual

Colima first run and file sharing. Disabling Tailscale key expiry for the Minis. Committing and applying the ACL policy. One token entry per client. None of these are hidden. They are rollout steps.

Rollout gates

Image builds on both Minis. The first client passes identity and attach. A drop and a recreate restore the conversation. A hard reboot returns the container. The tailnet-only proof passes. Green on all five before you onboard the first client.

Decision ledger

Decisions locked. Moving parts removed.

The design takes clear positions on purpose. Each decision removes a class of failure or a piece of plumbing that existed only because the old runtime could not do the job.

The short version: herdr owns the session, Tailscale owns access, one container owns one identity, and the Mac's disk owns the durable files. Numbering is stable: Review resolved D3 and D5. Names replaced the ports, so D8 no longer applies.

D1 · SESSION OWNER

herdr, per container

herdr's server owns the PTY and the named agent. Orca and Hermes are optional front-ends, not the runtime.

No custom listener to build. The agent state has one source of truth.

D2 · ACCESS

Tailscale, locked

One tailnet of devices and Minis. WireGuard direct, DERP fallback, identity + ACLs. No EC2, autossh tunnel, or published port.

Headscale is the only reason to keep a coordination box.

D4 · BASE IMAGE

node:24-bookworm-slim

Current Active LTS, pinned in the Dockerfile. Claude Code, gh, git, ssh and tmux fallback are installed at build time.

Rebuild and re-verify herdr when the base moves.

D6 · IDENTITY

One identity per container

One Claude login and one GitHub login per client. You can run more than one herdr agent or pane. They share that identity.

The default is a single named agent, FLEET_AGENT_NAME.

D7 · SHARED CONTENT

Skills and tools are read-only

shared-skills and shared-tools mount :ro everywhere. Only the per-client memory is writable.

The operator edits shared content on the Mac. Agents never do.

D9 · CLAUDE STATE

~/.claude stays a bind mount

Transcripts and Claude config live on the Mac's disk, inspectable and ready to copy off-box.

Inspectability beats a marginal I/O win from a named volume.

D10 · HERDR STATE

Named volume, never a macOS bind mount

herdr cannot run on virtiofs. It needs setxattr and O_TMPFILE. The macOS bind-mount layer does not support them.

A named volume is a real Linux filesystem inside the VM and still lives on the Mini's disk.

D11 · MAIN PROCESS

herdr server is the application

Entered through entrypoint.sh, pinned to v0.9.5 with a sha256 check. Config at /etc/herdr/config.toml, outside the state volume.

tmux stays installed as an emergency fallback, but is not started.

D12 · WHERE HERDR RUNS

Per container, never off-box

The Claude integration hook reports the session id over herdr's local socket. That is what makes resume exact: claude --resume <id>.

Corollary: down -v loses the ids, though the transcript survives.

D13 · MINI RUNTIME

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 colima-daemon.sh at boot as the operator, and that wrapper holds the VM open and restarts it if it stops. The laptop keeps Docker Desktop until T8.

Verified on the laptop. The system LaunchDaemon install and a real power cut are the gates.

D14 · IMAGE PATH

Build once, load on each Mini

Build my-claude-code:latest once for ARM64, then docker save | ssh <mini> docker load. One Dockerfile, one tag, identical bits. A local build alone never reaches another Docker daemon.

Build on a Mini only if that Mini has a different architecture.

D15 · OPERATOR UI

OliveTin as a thin button layer

Optional buttons over the same fleet.sh commands. It binds to 127.0.0.1 and is reached through an SSH tunnel. The scripts stay the source of truth. Two credential actions take a secret, and only those two. They use exec: so no shell parses the value, and OliveTin writes it to its log as <redacted>. Every other action takes no secret.

A non-technical operator gets a clear surface. Nothing depends on it.

D16 · NIX SCOPE

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.

Nix owns the CLI. launchd owns the boot path.

What the design subtracts

No per-client port. No per-client tunnel. No jump box. No autossh tunnel. No per-client launchd unit. No port registry. No custom agent listener. No claude --continue guess loop. D8 no longer applies: names replaced ports.

Independent assessment

Strong design. The constraint is evidence.

Two independent reviewers reviewed the design. The latest, stricter pass scored it 5.9 / 10. The architecture items score well. The lower scores are about what has actually been proven on a host.

That is a fixable gap, not a redesign. The plan below follows effect order: prove the runtime, then harden isolation, then improve day-2 operations.

“A well-reasoned design whose central promise is not yet proven on a host.”

The team built the runtime layers. They verified them in a real container. What is missing is the host-level acceptance run. You also need isolation hardening and the operational evidence that turns claims into a scorecard.

evidence gap rollout pending earlier pass: 6.8

What scores well

Problem fit. One container per identity and name-as-identity directly remove the logout shuffle. Core invariant. herdr as the container's main process is the right abstraction. External pins. herdr v0.9.5 and both sha256s match upstream. herdr config check reports config: ok for the pinned config.

What caps the score

Dev artifacts exist. Two clients, dev-a and dev-b, passed T1 to T3 on a laptop with Docker Desktop. Isolation gaps. Passwordless sudo in the image, and no committed ACL. Each client now has its own Compose network, and a cross-client probe is blocked. Reboot proof. The Colima chain passed a VM-restart test on the laptop. A Mini power cut and the system LaunchDaemon install are still open.

What changes it

Prove it. Add a smoke test, fleet.sh doctor, CI and a verification doc. Harden it. Add per-client networks, least-privilege sudo and a committed ACL. Operate it. Add health checks, status and update commands, and capacity data.

TierWorkEffortProjected score
1 · Prove itSmoke test (build → attach → drop → restart → recreate), fleet.sh doctor, CI, docs/verification.md and a backup timer.S to M~8.0
2 · Harden itPer-client Docker network, least-privilege sudo, committed ACL policy, threat model, down -v guard.M~8.5
3 · Operate itfleet.sh status and update, healthcheck on herdr status server, capacity measurements, bootstrap UX.M~8.8+

Projections are the reviewer's improvement plan, not guarantees. Tier 1 needs no new vendors and no cloud spend.

Open gaps, ranked

  • P1 · Acceptance run not executed. No end-to-end host proof yet.
  • P1 · Containers now have one network each. Cross-client reachability was measured as blocked. The shared-skills and shared-tools mounts are still a common path.
  • P1 · Reboot recovery not proven on a Mini. The boot chain is launchd, Colima, Docker, compose. It passed a VM-restart test on the laptop, but has not run on a Mini.
  • P1 · Mini runtime partly proven. Colima on arm64 with virtiofs is measured on the laptop, including uid mapping and a VM restart. The system LaunchDaemon and a real power cut are the gates.
  • P2 · Passwordless sudo for the container user.
  • P2 · ACL policy not committed as a repo artifact.
  • P2 · claude-token.txt. Generated with claude setup-token and stored by credential-set.sh. The entrypoint exports it at boot.
  • P2 · FLEET_MINI labels the registry only. Re-running new on both Minis can duplicate an identity.
  • P3 · No healthcheck, CI or doctor. Day-2 operations are manual.
  • P3 · Stale status copy in the spec and PRODUCT.md.

The ask

1. Approve the locked architecture: herdr runtime, Tailscale access, one identity per container.

2. Authorize the Tier 1 evidence sprint: smoke test, doctor, CI, reboot proof, backup fix. No new cloud spend.

3. Green-light two pilot clients once the five rollout gates pass.

What leadership should not assume

This is not yet a fully unattended service. A VM restart on the laptop proved that the container, its data and its agent return with no manual step. The Mini still has to pass the same test with a system LaunchDaemon and a real power cut. Treat the reboot path as a gate, not a finished feature.

Definition of done

The checklist that ends the debate.

Green on every line means the logout shuffle is dead: pick a name, connect, instruct. Anything still amber is a rollout gate, not a hidden assumption.

The verified column is runtime evidence from a real container. The pending column is the host and multi-client acceptance run.

Verified in the current build

Runtime contract

  • herdr status server answers. tini is PID 1 and herdr is its main child.
  • At boot, the runtime creates the workspace and the named agent. The agent reaches idle.
  • docker restart restores the pane and layout.
  • A full recreate restores the pane from the named volume.
  • The supervisor restarts a dead agent.
  • docker stop saves the session on SIGTERM.
Pending host acceptance

Rollout contract

  • Both Minis reboot. Every container returns without a manual step.
  • A long task survives an SSH or tailnet drop. A re-attach finds the same run.
  • A restart and a full recreate restore the pane. Conversation resume stays unproven until an authenticated session runs it.
  • gh auth status is correct in every container after reboot.
  • Laptop and phone reach every client by name.
  • One real task per client. The commit authors match the pinned accounts.
  • A shared skill edit appears everywhere. Containers cannot write to it.
  • Each client sees its own memory only. Cross-client secrets stay invisible.
  • A source edit inside the container lands on the Mac and survives rebuild.
  • Containers reach npm, gh and Anthropic over the Mini's network. Nothing inbound is reachable.
  • Tailnet-only proof: drop a client off the tailnet. The agents keep working.
  • One container runs more than one agent within the CPU and RAM cap.
  • Revoking a device cuts its access instantly. No public endpoint exists.

Definition of done

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

Risk and cost

What could go wrong, and what it costs.

The cost model is deliberately boring: no cloud compute, no jump box, no relay for us to operate. The risks are operational. The table lists each one with a mitigation and a status.

The honest summary: the design is cheap to run and private by construction, but the unattended reboot path and the isolation hardening still need proof.

No cloud computeno EC2 · no jump box · no relay
Existing hardwaretwo Mac Minis and their disks
Verify licensingTailscale plan · Colima is free
Bounded operator timeone bootstrap per Mini · one onboarding per client
RiskImpactMitigationStatus
Unattended reboot is not proven on a MiniHighlaunchd starts Colima, then Docker, then compose. The VM-restart test passed on the laptop. Run the same test on a Mini with a real power cutopen
Containers share one Docker networkMediumEach client has its own Compose network. Measured: dev-a cannot resolve or reach dev-b, while egress still worksfixed
Passwordless sudo inside the containerMediumRestrict sudo to package operations, or drop it. Add no-new-privilegesopen
down -v deletes the herdr volumeMediumRunbook guard, backup before destructive commands, transcript survivesopen
Backup script volume-name mismatchMediumFixed: the script uses the fleet_ prefix, and restore.sh restores a volume with the right ownerfixed
herdr is pre-1.0 and moves fastMediumVersion and sha256 pinned. Rebuild and re-verify on bumpmitigated
Residential-IP / account abuse checksMediumOne human-scale login per account. Never share a loginaccepted
Tailscale control plane or DERP dependencyLow to mediumDERP is an encrypted fallback. Headscale is optional for self-hosted coordinationaccepted
Docker Desktop commercial licensingLowRemoved from the Mini path: Colima is free. The laptop keeps Docker Desktop for development onlymitigated

Open questions for the sponsor

Which two clients are the pilot? Who owns the Tailscale ACL policy and tailnet plan? Who signs off the reboot acceptance run? Which Mini do we boot-test first?

Why the cost story is strong

No hourly cloud compute. No public IP. No jump host to patch. You already own the expensive part: the Mac Minis and their disks. The remaining spend is licensing to confirm. You add no infrastructure.