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.
Internal proposal · runtime verified · rollout pending
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
Illustrative target state · no client onboarded yet
Approve a two-client pilot on the two existing Minis.
Existing hardware. No new cloud compute.
You onboard the first client only after all five rollout gates pass.
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.
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.
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
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.
Swipe or drag to pan · Expand for full size
The shift
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.
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.
A compromised or confused agent sees its own credentials, its own source, and its own memory. It does not see another client's login.
acme is a container and an agent. The name survives a rebuild. A port number never did. The registry is the contract.
No jump box, no published port, no inbound path. Access is proven by tailnet identity and constrained by ACLs.
Architecture
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.
mini-a and mini-b.api.anthropic.com, github.com and npm via Docker NAT on the Mini's own network.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.
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.
That client's Claude login and GitHub login, a :ro secrets mount, per-client source and memory, read-only shared skills and tools.
Docker NAT sends model, git and npm traffic out over the Mini's IP. No inbound path. The access layer never carries model traffic.
| Layer | Installed by | What it provides |
|---|---|---|
| Mac Mini host | mini-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 image | Dockerfile + entrypoint.sh | Node 24, git, gh, ssh, tmux fallback, Claude Code, and herdr v0.9.5 pinned with a sha256 check. |
| Container runtime | Rendered compose + fleet.sh up | Per-client mounts and env, init: true, restart: unless-stopped, CPU/RAM caps, no published ports. |
| Access | Tailscale + ACLs | MagicDNS, Tailscale SSH, device identity, revocable access, DERP fallback. No jump box or public port. |
The boundary that matters
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.
| Traffic | Path | Uses the tailnet? | Public inbound? |
|---|---|---|---|
| Attach, keystrokes, prompts | Device → tailnet → Mini SSH → herdr | Yes | No |
| Model calls | Container → Docker NAT → api.anthropic.com | Never | No |
| Git push / pull | Container → Docker NAT → GitHub | Never | No |
| Package installs | Container → Docker NAT → npm registry | Never | No |
| Secrets and credentials | Mac disk → :ro mount → container | Neither | No |
| Backups | Mini local disk / your backup target | Neither | No |
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
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.
ssh -t mini-a. Tailscale resolves the MagicDNS name and authenticates the device.docker exec -it fleet-acme-1 herdr.# 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'
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
| Client | Container | Mini | Claude + GitHub | Attach |
|---|---|---|---|---|
| acme | fleet-acme-1 | Mini A | acme's Claude + acme's GitHub | herdr agent attach acme |
| borealis | fleet-borealis-1 | Mini A | borealis's Claude + GitHub | herdr agent attach borealis |
| cinder | fleet-cinder-1 | Mini B | cinder's Claude + GitHub | herdr agent attach cinder |
Example registry shape. The current build onboards no clients. The first onboarding is a rollout gate.
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
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.
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 paneThe 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.
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.
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.
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 · MagicDNSrestart: unless-stopped or fleet.sh up.claude --resume from the transcript in ~/.claude.| Event | Automatic? | Mechanism | Residual risk |
|---|---|---|---|
| SSH / tailnet client drops | Yes | herdr server owns the PTY | None for the agent |
| Agent process crashes | Yes, ≤ 30 s | entrypoint supervisor starts the named agent | Conversation recovery depends on the session id |
| Container process exits | Yes | restart: unless-stopped | Only after the Docker daemon is up |
| Container restart / recreate | Yes | named volume + resume_agents_on_restore | Lost only with down -v |
| Mini reboots | Conditional | launchd starts Colima, then Tailscale, then compose | No login needed. Measured as a VM restart on the laptop. A Mini power cut is unproven |
| SSH client reconnects | Operator / front-end | Re-run the attach command | No process restart. Just a new viewer |
| Session id missing | Manual | claude --resume from the transcript | Transcript survives in ~/.claude |
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
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.
down -v moves the session id out of reach.fleet.sh new → provisioned. fleet.sh up → starting → running.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.| What | Where it lives | Restart | Recreate | down -v |
|---|---|---|---|---|
| herdr layout + session ids | Named volume fleet_herdr-<client> | Survives | Survives | Lost |
| Claude transcript | ~/.claude bind mount on the Mac | Survives | Survives | Survives |
| GitHub auth | ~/.config/gh bind mount | Survives | Survives | Survives |
| Client source | ~/Code/<client> bind mount | Survives | Survives | Survives |
| Secrets | identities/<client> · read-only | Survives | Survives | Survives |
| Shared skills / tools | shared-skills, shared-tools · read-only | Survives | Survives | Survives |
| Per-client memory | state/<client>/memory · writable | Survives | Survives | Survives |
docker compose down -v is the one footgunIt 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.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
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.
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.
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.
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 path | Source on the Mac | Mode | Boundary |
|---|---|---|---|
/home/node/.claude | state/<client>/claude | rw | That client's transcript and config |
/home/node/.config/gh | state/<client>/gh | rw | That client's GitHub auth |
/secrets | identities/<client> | ro | That client's PAT, not in git, not in a shared mount |
/work | ~/Code/<client> | rw | That client's source on real disk |
/memory | state/<client>/memory | rw | That client's memory only |
/shared/skills | shared-skills/skills | ro | Every container. Only the operator edits the Mac side |
/shared/tools | shared-tools | ro | Every container. Read-only by design |
/home/node/.config/herdr | named volume | rw | That client's layout and session ids |
{
"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"]
}
]
}
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.
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
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.
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.tag:mini, enable Tailscale SSH, disable key expiry. Set FLEET_TS_AUTHKEY so the join needs no browser.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.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.gh auth status and herdr agent list. Then attach by name from laptop or phone.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 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.
fleet.sh new, paste secrets, fleet.sh up. Log in once, then verify which identity answers.
Kill the SSH client mid-task, re-attach, then restart and fully recreate the container. The same conversation must come back.
Bootstrap it, onboard another identity, then run the reboot and tailnet-only acceptance checks. Only then call it production.
./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
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.
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.
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
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.
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.
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.
node:24-bookworm-slimCurrent 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.
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.
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.
~/.claude stays a bind mountTranscripts 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.
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.
herdr server is the applicationEntered 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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
| Tier | Work | Effort | Projected score |
|---|---|---|---|
| 1 · Prove it | Smoke test (build → attach → drop → restart → recreate), fleet.sh doctor, CI, docs/verification.md and a backup timer. | S to M | ~8.0 |
| 2 · Harden it | Per-client Docker network, least-privilege sudo, committed ACL policy, threat model, down -v guard. | M | ~8.5 |
| 3 · Operate it | fleet.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.
claude-token.txt. Generated with claude setup-token and stored by credential-set.sh. The entrypoint exports it at boot.FLEET_MINI labels the registry only. Re-running new on both Minis can duplicate an identity.PRODUCT.md.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.
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
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.
herdr status server answers. tini is PID 1 and herdr is its main child.idle.docker restart restores the pane and layout.docker stop saves the session on SIGTERM.gh auth status is correct in every container after reboot.Runtime contract green and rollout contract green. Until then, the honest status is: runtime verified, host rollout pending, isolation hardening next.
Risk and cost
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.
| Risk | Impact | Mitigation | Status |
|---|---|---|---|
| Unattended reboot is not proven on a Mini | High | launchd 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 cut | open |
| Containers share one Docker network | Medium | Each client has its own Compose network. Measured: dev-a cannot resolve or reach dev-b, while egress still works | fixed |
| Passwordless sudo inside the container | Medium | Restrict sudo to package operations, or drop it. Add no-new-privileges | open |
down -v deletes the herdr volume | Medium | Runbook guard, backup before destructive commands, transcript survives | open |
| Backup script volume-name mismatch | Medium | Fixed: the script uses the fleet_ prefix, and restore.sh restores a volume with the right owner | fixed |
| herdr is pre-1.0 and moves fast | Medium | Version and sha256 pinned. Rebuild and re-verify on bump | mitigated |
| Residential-IP / account abuse checks | Medium | One human-scale login per account. Never share a login | accepted |
| Tailscale control plane or DERP dependency | Low to medium | DERP is an encrypted fallback. Headscale is optional for self-hosted coordination | accepted |
| Docker Desktop commercial licensing | Low | Removed from the Mini path: Colima is free. The laptop keeps Docker Desktop for development only | mitigated |
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?
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.