RFC-001: Claudio Architecture
- Status: Draft
- Author: Rodrigo Morales
- Created: 2026-09-05
- Updated: 2026-09-09
- Supersedes: —
- Tracking: Linear project
claudio
Summary
Claudio is an orchestrator that runs multiple sandboxed Claude Code sessions in Docker containers. Each session gets a checked-out repository, automatic port forwarding to the host, host-side filesystem access, and an interactive terminal attach.
The design rests on four load-bearing choices. The host owns the working tree — one clone per repository, one git worktree per session, bind-mounted into the container — so the developer's editor and host git work natively against a real directory. State is split so that SQLite records intent and Docker records reality, which removes drift by construction. Interaction is a real PTY via docker exec into tmux rather than a reimplemented chat protocol. And host ports are allocated dynamically from a range rather than mapped statically, which is what allows two instances of the same repository to coexist.
Phase 1 ships as a single Go binary with no daemon; the layering that defers the daemon is specified in CLI-first, daemon-ready.
Motivation
A developer wants to run several Claude Code agents in parallel, each working on its own copy of a repository, without those agents interfering with each other or with the host machine. Each agent needs:
- Isolation — a container boundary, so an agent's
rm -rf, dependency install, or runaway process cannot touch the host or a sibling session. - A real repository — cloned, on a branch, with credentials to fetch and push.
- Running services — if the repo is a web app,
npm run devinside the container must be reachable from the host browser. - Human interaction — the developer must be able to drop into the Claude Code session, read what it is doing, answer its questions, and steer it.
- Host file access — the developer must be able to open the cloned repo in their own editor, run
git diff, and inspect changes with native tools.
The orchestrator's job is to make creating, tracking, entering, and destroying these environments a single-command operation.
Design principles
These principles are the tie-breakers invoked throughout the rest of this document.
- The host owns the source of truth. The working tree lives on the host filesystem. Containers are disposable; the repo is not.
- The container is a sandbox, not a pet. Any instance can be destroyed and rebuilt from
(repo, branch, image)without losing work that has been committed or that lives in the bind-mounted tree. - State lives in one place. A single durable store on the host records every instance. Docker is queried to reconcile, never as the sole source of truth.
- Attach, don't proxy. Interaction with Claude Code is a real PTY, not a re-implemented chat protocol. The terminal the user gets is the terminal the agent sees.
- Ports are discovered, not configured. The orchestrator reads the repo to learn what it serves, and allocates host ports dynamically to avoid collisions between instances.
- Fail visible. Every instance has a status, a health signal, and logs the user can reach without knowing Docker.
- Single-user, local-only. One operator on one machine; no outside traffic, no shared instances, no multi-tenancy. This is a deliberate scope limit, and it removes whole categories of design: no auth layer, no access control beyond file permissions, no read-only viewers, no network exposure surface. Published ports bind to
127.0.0.1; the control socket is a Unix socket.
Guide-level explanation
This section is the orientation: what the system is made of, what an instance is, and what the user types. The Reference-level explanation that follows specifies each subsystem in detail.
System overview
The instance model
An instance is the unit the orchestrator manages: one container + one workspace + one Claude Code session + a set of port mappings.
Lifecycle
STOPPED keeps the workspace on disk and the port reservations released. DESTROYED removes the container and, unless --keep-workspace, the workspace directory.
The PROVISIONING state is a sub-state machine, and each step is idempotent so a crashed daemon can resume:
- Ensure the repo root exists (clone once if new); create
~/.claudio/instances/<id>/{home,logs} - Add a git worktree for this session and rewrite its gitdir pointers to relative paths (Workspace layout)
- Detect ports and services (Port forwarding)
- Reserve host ports
- Materialize per-instance config (
.claudio/resolved.yml) - Create the container with mounts and port bindings
- Start the container; supervisor launches tmux + Claude Code
- Health-probe; transition to RUNNING
CLI surface
Identity: the generated ID (brave-otter) is permanent and canonical; --name sets an optional global-unique alias that resolves to it, and both work anywhere an <id> is accepted. Keeping the generated ID canonical is what stops the default branch (claudio/<instance-id>) and container names drifting when an instance is renamed. Unambiguous prefixes are accepted.
claudio cd matters more than it looks: the answer to "how do I get at the files" should be one command, not a path the user has to remember.
What the host gets
The host has a real directory at ~/.claudio/repos/<repo>/worktrees/<instance-id>:
- Open it in any editor or IDE; no remote-container extension required.
- Run host
gitagainst it —diff,log,add -pall work natively. claudio cd <id>prints the path; a shell function wraps it forcd $(claudio cd <id>).
This is the concrete answer to the "host access to the cloned repo folder" requirement: it is not exported, synced, or copied out — it is the working tree, and the container is the thing mounting it.
Reference-level explanation
Filesystem and repository strategy
Workspace layout
One clone per repo; one git worktree per session. The worktree directory is what the container mounts.
Cloning happens once per repo rather than once per instance, so the second and subsequent sessions on a repo are a git worktree add — seconds, not minutes — and they share the object store instead of duplicating it.
Cloned once, but refreshed on every create. Reusing the clone is what makes the second session fast; it is also what made every session after the first stale. main-clone was cloned on the first create and never fetched again, so each new worktree branched from whatever the source held at first-clone time and drifted further behind for the life of the repo — silently, since nothing about the resulting worktree looks wrong. Verified in both directions: an inheritance test failed as written and passed after a manual git fetch + git reset --hard.
claudio create refreshes main-clone before cutting the worktree. What that means depends on where the commits actually come from:
Three details worth stating, each of which was a wrong first attempt:
- The upstream is read from the source directory, not from
main-clone. Cloning does not copy remotes — verified:main-clone's ownoriginpoints at the source directory, never at that directory's GitHub.main-clonehas no record of the real upstream at all. - Classification is redone on every create, never recorded at create time. That is what lets a prototype which later gains an origin (the user pushed it to GitHub) start being refreshed on its next create, with no migration and nothing to re-run.
- The refresh is
fetch+reset --hard, notupdate-ref. Moving the ref alone leavesmain-clone's working tree and index stale — verified: that produces a phantom staged deletion for every file the new commits added. This cannot destroy user work:main-cloneis Claudio-managed, is never handed to a user or a container, and permanently holds the one branch git forbids any worktree from checking out.
--no-refresh skips it (offline, or a deliberately pinned clone), and --base-branch <b> names the branch non-interactively. An unreachable upstream degrades to a warning and the existing clone rather than failing the create.
It also makes "one instance = one line of work" structural rather than conventional: git refuses to check out the same branch in two worktrees, so parallel agents cannot collide on a branch even by mistake. That refusal surfaces as a clear error naming the instance already holding it.
The worktree is a plain host directory — openable in any editor, usable with host git. That is the answer to "the host needs access to the folder where the repo is cloned". The root is configurable, defaulting to ~/.claudio.
Repository sources
A remote GitHub URL, or a local directory. claudio create accepts git@github.com:acme/web.git, an HTTPS URL (normalized to SSH), or the acme/web shorthand. The clone runs on the host, using the host's existing SSH setup, which is what lets the container provision without ever holding git credentials (Credentials).
claudio create . (or any path-shaped argument: ., .., ./x, ~/x, or an absolute path) clones from a local directory instead, for local-only or not-yet-pushed work (ROD-115). A directory that is not yet a git repo is git inited and its contents committed first, so unversioned work gets an instance without any setup ceremony.
This was originally ruled out on the grounds that it "invites confusion about whether uncommitted work and local-only branches come along." Tested rather than assumed, git clone <path> answers that unambiguously, and the answer is the one a user would want:
So an agent is never handed a half-finished edit the user has not decided to keep, and nothing has to be pushed to GitHub first. The clone is self-contained (no .git/objects/info/alternates), so work inside the sandbox cannot corrupt the source repo's object store, and origin points back at the local directory — an agent can push a finished branch straight home. A source repo that already exists is never written to: Claudio does not commit on the user's behalf there.
The path check is deliberately conservative: acme/web stays a GitHub shorthand even when a directory by that name exists in the cwd, so adding this never silently changes what an existing command meant.
Initiatives without an upstream repo get the same structure. claudio create --new market-research creates a root, git inits it, and works off a worktree exactly as a cloned repo does. Research, analysis, and writing are not second-class: they get the same isolation, the same real history, and the same diffable output.
Branch handling
Branch handling distinguishes the two intents rather than overloading one flag:
A collision is not merely an error — it names the holder and offers a way forward:
The suggestion is the requested name with a numeric suffix past any existing collision. --yes accepts it; a non-TTY invocation errors with the suggestion in the message rather than hanging.
Worktrees across the container boundary
A worktree's .git is a file, not a directory: it contains a path to <root>/main-clone/.git/worktrees/<name>, which lives outside the worktree. Mounting only the worktree therefore breaks git inside the container — verified: fatal: not a git repository: (null).
Four approaches were tested (Appendix B):
The last is the design. Both pointers — the worktree's .git and the reverse gitdir file in the root — are rewritten to relative paths, and the container mounts the entire repo root with the worktree as its working directory:
Verified end to end: a commit made inside the container appears immediately on the host, with host git fully functional throughout. Rewriting the pointer to a container-only path is the tempting shortcut and it breaks the host — one .git file cannot hold two paths, so relative pointers are the only arrangement that satisfies both sides at once.
Dependency installation
Dependency installation is declared, never assumed. Provisioning runs no implicit npm install; a repo that needs one says so:
This keeps create fast and predictable, and keeps the tool from guessing what a project's setup step should be (Config schema).
home/ is bind-mounted to /home/agent. This makes the Claude Code session's own state — conversation transcripts, settings, shell history — durable across container rebuilds and readable from the host.
Dependency directories: bind-mounted, not offloaded
Heavy dependency directories (node_modules, .venv, target/) are the worst case for a bind mount: hundreds of thousands of small files that the toolchain then stat()s constantly. The obvious optimization is to put them in a Docker named volume, which is genuinely much faster.
This was investigated in depth and rejected. The full evidence is in Appendix A; the summary is:
- A named volume is 7–15× faster on metadata operations (
statwalks,grep -r,rm -rf) — measured on this machine, not assumed. - On OrbStack the host can see into a volume (
~/OrbStack/docker/volumes/<name>), and a host symlink into it round-trips correctly in both directions. On Docker Desktop it cannot: volumes live inside one opaque disk image. - But the container must also mount the volume at that path, because the host symlink stores an absolute macOS path that dangles inside the container. And a fresh volume's root is
0:0, so the non-root agent getsPermission denieduntil it is chowned.
The decision rests on a simpler point than any of that detail: offloading's cost is that the host cannot properly see the offloaded directory, and host access to the working tree is the whole reason this project exists. A tool built to give the host first-class access should not ship a feature whose price is taking it away. The target runtime already has fast bind mounts, and the feature would carry a runtime-conditional code path, chown handling, symlink retry logic, EXDEV/pnpm interactions, and case-sensitivity divergence between APFS and ext4.
So: bind-mount everything, dependency directories included. If dependency I/O ever becomes a measured bottleneck, the evidence to revisit this is preserved rather than lost.
For repos that genuinely feel the cost, the better answer is upstream: Yarn PnP (or pnpm's node-linker=pnp) replaces ~100k small files with one zip per package and uses no hardlinks at all, attacking the root cause instead of routing around it.
Port forwarding
Detection
The orchestrator determines which ports a repo's apps and services listen on, in precedence order (later overrides earlier):
-
Framework defaults by project type — detected from manifest files.
-
docker-compose.yml/compose.yamlin the repo — parse theports:andexpose:keys of every service. This is the highest-signal source, because it is the repo's own declaration of what it serves. Services here are also candidates to run as sidecar containers (Compose-based repositories). -
package.jsonscripts / Procfile / Makefile — regex for--port N,-p N,PORT=N. -
.env,.env.example—PORT=,APP_PORT=,DATABASE_URL=postgres://…:5432/…. -
.claudio.ymlin the repo — explicit declaration, always wins: -
Runtime discovery — a lightweight watcher inside the container polls
/proc/net/tcp{,6}for newLISTENsockets. When the agent starts a server on an undetected port, the daemon can bind it on demand (Dynamic port binding).
Detection is a ranked guess; every mapping records its source so claudio ports <id> can show the user why a port is mapped and let them correct it with --add / --remove.
The --ports flag on create supplements detection rather than replacing it, and wins on conflict (recorded as source: manual). Wholesale override would force re-declaring every port just to add one; supplementing matches the actual case — "the detected ports are right, I also want the debugger exposed."
--ports names container ports only (--ports 9229, not 9229:9229). The host side is never the caller's to choose: first-free-in-range allocation is what allows a second instance of the same repo to exist at all, so pinning a host port would reintroduce precisely the collision that design eliminates. The container:host form is rejected with an error naming the bare form, rather than accepted-and-ignored — a syntax that reads as a pin but silently does nothing is worse than one that refuses.
Host port allocation
Static mapping (container 3000 → host 3000) breaks the moment a second instance exists. Instead:
- A host port range, default
43000–43999, configurable as a fallback when the default does not suit the machine (conflicting local services, corporate port policy). Exhausting it is a clear error naming the range and how to widen it, not a cryptic bind failure. - Allocation is first-free-in-range, recorded in SQLite, and verified by an actual
bind()probe on127.0.0.1before being committed — this catches ports held by non-Claudio processes. - Allocations are released on
stopanddestroy. A crashed daemon's allocations are reclaimed on startup by reconciling against live containers. - Bindings are to
127.0.0.1by default, not0.0.0.0— a sandboxed agent's dev server should not be exposed to the local network.--publish-all-interfacesopts out.
The user sees a stable table:
Dynamic port binding
Docker cannot add a port binding to a running container. Two options were considered:
- (A) Recreate the container when a new port appears. Correct but disruptive — it kills the Claude Code session.
- (B) Userspace forwarder on the host. The daemon runs a small TCP proxy: it listens on the allocated host port and dials the container's IP:port directly over the Docker bridge network.
Decision: (B), with (A) available as claudio rebind <id> --recreate.
Rationale: the container's IP is reachable from the daemon directly on Linux, and on macOS through the runtime's VM network (OrbStack routes container IPs to the host natively; Docker Desktop requires the proxy to dial via the VM). Option B lets a port appear seconds after the agent starts a server, with no session loss. The proxy is ~100 lines (net.Listen + io.Copy both ways) and its lifetime is tied to the instance.
To keep the common case fast, the pre-detected ports are bound natively by Docker at container creation; only runtime-discovered ports use the proxy.
Compose-based repositories
If the repo has a docker-compose.yml, the instance is not a single container but a compose project:
- The daemon generates an override file that
- joins all services to a per-instance network
claudio_<id>, - rewrites every published port to the daemon-allocated host port,
- adds the Claudio agent container as an extra service on the same network,
- bind-mounts the workspace into the agent container.
- joins all services to a per-instance network
- The agent container reaches
postgres:5432by service name, exactly as the repo expects. docker compose -p claudio_<id>namespaces everything, so N instances of the same repo coexist.
This is the main reason the port allocator must rewrite rather than pass through: two instances of the same compose file would both want host 5432.
Services are sidecars; client tools are in the image. Postgres, Redis, and Elasticsearch run as their own containers with their own lifecycle and per-instance isolation. What goes into the agent image is the tooling that talks to them — psql, redis-cli, native build dependencies — declared through Image strategy's YAML. The agent container should never run the service it is developing against.
Docker-in-Docker is not the mechanism. Nesting a container runtime inside the agent container is explicitly rejected: mounting the host Docker socket is a direct host-root escalation path (Sandbox posture). A repo that genuinely needs the agent to run docker compose itself gets a rootless DinD sidecar, opt-in per instance.
A repo may also declare services in .claudio.yml without shipping a compose file at all; these are synthesized into the same per-instance project:
Sidecar lifecycle is coupled to the instance: stop stops the project, destroy removes it including sidecar volumes, health appears in claudio status, and claudio logs <id> --service db reaches sidecar logs — an agent blocked on a database that failed to start should be diagnosable without dropping to docker ps.
The container
Image strategy
Base image (claudio/base) — FROM node:22-slim, plus git, curl, ripgrep, tmux, tini, a non-root agent user, and the Claude Code CLI. Node is required for Claude Code itself regardless of what the repo needs, so starting from the official Node image beats installing Node onto bare Debian.
Node-only to start. Rather than maintaining a matrix of prebuilt toolchain images, the image builder is driven by YAML, so additional dependencies are declarative:
The builder generates a Dockerfile from this. Adding Python, Go, or Rust later becomes a config change rather than a code change, and repos with unusual system dependencies do not need Claudio to ship an image for them.
UID/GID matching is a build arg, not a baked constant. The image is built with USER_UID/USER_GID matched to the host user (here 501:20, against Debian's default 1000:1000) and cached per host — the same approach devcontainers take. Since /workspace is a bind mount, every file the agent creates carries the container's UID; OrbStack auto-maps ownership so a mismatch mostly works there, but it fails differently on Docker Desktop and native Linux. The failure this prevents is root-owned files appearing in the user's repo that need sudo to remove.
Repo layer (optional) — if the repo has .devcontainer/Dockerfile or .claudio/Dockerfile, it is built FROM the resolved base as an escape hatch. An explicit image.dockerfile path in .claudio.yml takes precedence over both. The escape hatch replaces the generated Dockerfile rather than layering beneath it: a repo that supplies its own Dockerfile owns the whole build.
Building is explicit, never a side effect of create. claudio image build [--repo <path>] is its own verb; create only resolves which image an instance needs and fails with an actionable error naming the exact build command when it is missing. A multi-minute, network-dependent Docker build must not happen as a surprise inside what the user asked to be a fast provisioning step. The per-repo tag is derived from the repo's identity (its origin remote where parseable, else a file:// path form), so image build --repo <clone> and a later create <same-url> land on the same tag without either command knowing about the other.
The installer runs it; create still does not. ./install.sh builds the base image as its last step, which is not an exception to the rule above — it is the rule working. Onboarding is exactly the moment a multi-minute build is what the user asked for, so it happens there, once, where it is announced and skippable (--skip-image), rather than ambushing the first create. The invariant is unchanged: no command that is supposed to be fast ever builds an image.
Devcontainer compatibility is a deliberate goal: .devcontainer/devcontainer.json already encodes image, features, forwarded ports, and post-create commands. Where it exists, Claudio reads it and treats it as a higher-precedence source than its own detection.
Process model inside the container
PID 1 is tini, with a small supervisor as its child — this gets zombie reaping and signal forwarding for free rather than hand-rolling SIGCHLD handling. Claude Code is not PID 1. The supervisor starts:
Claude Code runs inside tmux rather than as PID 1 so that:
- The user can attach and detach without signalling the process.
- Multiple viewers can attach to the same session simultaneously.
- The session survives a client disconnect (SSH drop, laptop sleep, terminal close).
- Scrollback is preserved and greppable.
The pane runs while true; do claude && break; bash -l; done rather than claude alone (ROD-116). The pane's process is the session's only process, so its exit status decides the session's fate — which is exactly the control wanted.
Quitting Claude Code deliberately (a double Ctrl-C, which exits 0) breaks the loop, so the pane exits, tmux tears the session down, the docker exec behind claudio attach returns, and the user lands back on their host shell. That is what "closing a session" should mean, and it is why the loop is conditional.
A nonzero exit — a crash, a bad credential — falls through to an interactive shell instead. The session stays up with a pane the user can type into, so a broken instance is something to attach to and debug rather than one that silently disappeared; leaving that shell retries Claude Code.
The original bug was the unguarded version of this: with a bare shell as the pane's process, exiting it destroyed the session and, this being the only session, the whole tmux server. The container stayed Up regardless (the entrypoint's tail -f holds it open, not tmux), so claudio ls kept reporting a healthy instance that claudio attach could no longer reach.
Two alternatives were tried and rejected. remain-on-exit on keeps the session but leaves a dead pane, stranding a user who quits while attached on "Pane is dead" with no way to type — worse than the bug it fixed. An unconditional while true; do claude || bash -l; done relaunches Claude Code the instant it is quit, so there is no way out of the session at all.
Fast provisioning
Cloning a large monorepo per instance is slow. Mitigations:
- Worktrees, not repeated clones. The repo is cloned once per root; every subsequent session is a
git worktree addsharing the same object store. Provisioning drops from minutes to seconds without any reference-clone machinery. - Shared package caches.
~/.claudio/cache/{npm,pip,cargo,go}is bind-mounted read-write into every container at the toolchain's cache path. Installs in instance N+1 hit a warm cache. - Image prewarming. The daemon keeps the last-used toolchain images pulled.
Sandbox posture
The container is a security boundary against accident, and a partial boundary against malice. Explicitly:
- Non-root
agentuser; no--privileged;--cap-drop=ALLplus only what the toolchain needs. --security-opt no-new-privileges.- Read-only root filesystem where the toolchain tolerates it, with
tmpfsfor/tmp. - Memory and CPU limits per instance — 6 GB / 4 CPUs on this machine, set globally and overridable per repo, and overridable again locally with
claudio create --memory M --cpus N --pids N(Configuration layering's three-layer resolution: global < repo < local).createreports when a local override changes what the repo's own.claudio.ymlrequested, rather than substituting a different number silently. Sized against the OrbStack VM's 15.7 GB, not the host's 36 GB: the VM cap is what containers actually share, and sizing against host RAM overcommits by more than 2×. - PID limit (512) to contain fork bombs.
- An exceeded limit must be legible. Docker exposes
OOMKilledin container state;claudio statusreports "killed: out of memory (limit 6g)" with the command to raise it, rather than a bareSTOPPED. A limit that produces a baffling failure is worse than no limit — the reconciler would otherwise show a stopped container with no cause.createalso warns when configured limits across running instances would exceed the VM's memory. - No Docker socket mount by default. Mounting
/var/run/docker.sockinto an agent container is a host-root escalation path. Repos that genuinely need Docker-in-Docker get a rootless DinD sidecar, opt-in per instance. - Network egress policy. Default: unrestricted (agents need npm, PyPI, GitHub, the Anthropic API). Optional
--network-policy=restrictedattaches the container to a network whose egress passes through a filtering proxy with an allowlist. - Credential scoping — see Credentials.
The honest caveat: a container is not a VM. A kernel exploit escapes it. For hostile code, the recommendation is a VM boundary (Lima/Colima with a dedicated VM per instance), which the architecture accommodates because the daemon talks to a Docker endpoint, not necessarily the local one (Remote Docker endpoints).
Credentials
Three distinct secrets, three different handling rules:
The credential proxy is the important one: it means an agent can push a branch without ever holding a token it could exfiltrate, and every push is attributable and optionally gated.
Anthropic authentication
The credential is a subscription token, not a Console API key. claude setup-token converts an existing Claude subscription into a long-lived token, so instances bill against the subscription rather than metered API usage. (Verified on the development machine: claude auth status reports authMethod: claude.ai, subscriptionType: max, with no ANTHROPIC_API_KEY set.)
Flow: on first claudio create, if no credential is stored, run claude setup-token interactively and capture it — a one-time setup step. Store it in the host keychain, and inject it into each container's environment at start.
Two placement rules matter:
- Not under
~/.claude/. Claudio must not write into Claude Code's own config tree; a prune or rewrite there would take the credential with it.~/.claudio/is already the state directory. - Not per-instance files. An earlier design minted a credential per container into
~/.claudio/auth/instances/<id>/. Rejected:setup-tokentakes no arguments — no scope, no expiry, no label — and mints one long-lived token, so per-instance directories would hold N copies of the same secret. A file on disk is also no less readable to the agent than an environment variable; the separation buys lifecycle tidiness, not isolation, while adding cleanup paths that leak credentials when missed.
Known limitation, accepted for phase 1: this is the same credential in every container. An agent that reads it holds the user's subscription token, and revoking it kills every instance at once. The credential proxy above is the actual fix — the container holds nothing and the host adds auth to outbound requests — and Anthropic auth should be folded into that same broker alongside git rather than built as a second mechanism.
Host ↔ session interaction
Attach (primary path)
Execs docker exec -it claudio-brave-otter tmux new-session -A -s claude. The user gets the real Claude Code TUI with full fidelity: colors, resize, Ctrl-C, the lot. Detach with the tmux prefix (Ctrl-b d) — the session keeps running.
new-session -A rather than attach -t so that attach is self-healing (ROD-116): it attaches when the session is there and creates it when it isn't. A container's tmux session can be gone while the container itself is still Up — tini and tail -f hold the container open independently of tmux — and attach -t in that state failed with tmux's bare "no sessions" plus Docker's generic "try docker debug" hint, a dead end mid-workflow.
Both attach and logs exec with DOCKER_CLI_HINTS=false. On exit from an interactive docker exec -it, the Docker CLI prints a "What's next: Try Docker Debug ..." promo; because these commands replace the Claudio process outright, that text arrives in the user's terminal as though Claudio had printed it — quitting a session ended with an unprompted docker debug claudio-<id> suggestion that is not a Claudio workflow and reads as an error where none occurred. It is prepended rather than appended so a user who sets the variable themselves still wins.
The recreate path passes the same pane command the entrypoint uses (session.PaneCommand, see Process model inside the container), so a session rebuilt by attach comes back running Claude Code rather than dropping the user at a bare container shell. tmux applies that command only when -A actually creates the session and ignores it when attaching to an existing one, so an ordinary attach is unaffected.
The CLI does not proxy this through the daemon. Inserting a daemon hop between two TTYs adds latency and breaks window-resize propagation for no benefit.
Rebuilding an instance onto a new image
claudio rebuild <id> [--fresh] builds the image, then re-provisions the instance's container from it — claudio image build followed by claudio restart, as one verb.
It exists because a change to the image — image/entrypoint.sh most of all — reaches a running instance through neither of the routes an operator reaches for first. Rebuilding the CLI does nothing, since the entrypoint ships in the image rather than the binary. claudio restart re-provisions from whatever image is tagged now, but never rebuilds it, so a stale tag is faithfully reused. Both commands report success, and the instance keeps running the old entrypoint.
That failure is silent, which is what makes it worth a command: claudio ls shows a healthy instance, the CLI is current, and nothing anywhere says the container predates the image. The fix was found only by comparing image IDs by hand (ROD-116). Pairing the two steps in one verb means the operator does not have to know the pairing to get it right.
The summary line reports the drift the rebuild closed (Image 4e1269969011 -> 98e847299a9f), and says so explicitly when the image did not change — distinguishing "the fix isn't in the image" from "the fix is in, look elsewhere" is the whole diagnostic value.
--fresh discards home/ exactly as it does for start/restart; the default resumes the existing session.
Non-interactive control
For scripting and the web UI, the daemon exposes:
claudio logs ships earlier than the rest of this section: phase 1 already has it as a plain syscall.Exec into docker logs/docker compose logs, no daemon involved — the same "no hop between the CLI and Docker" reasoning as attach. What the daemon adds later is aggregation across sessions and a stream the web UI can subscribe to without shelling out itself.
send writes to the tmux pane via tmux send-keys, which is how the session receives input regardless of whether a human is attached.
Activity and attention
The most valuable signal in a multi-agent setup is which session needs me. State comes from Claude Code hooks, not from screen-scraping — pattern-matching tmux capture-pane output would break whenever the TUI changes, whereas hooks are a supported interface.
The hook events distinguish two genuinely different kinds of blocked:
That distinction matters for ranking: a permission request is usually urgent, a finished turn often is not. Hooks are configured as type: "http" posting to the container's agent-bridge, carry session_id in every payload, and fire in headless tmux with no client attached — which is precisely the case when nobody is looking.
Two constraints on the hook config: timeout: 5 and async: true. Hooks default to a ten-minute timeout, so a slow agent-bridge could otherwise stall the very session it is meant to observe. The monitor must never be able to block the agent.
A pane-quiescence heuristic remains as a fallback for cases hooks miss (crash before SessionEnd, failed delivery, bridge down). It reports IDLE (unconfirmed) rather than asserting a state that was never actually observed.
claudio ls sorts blocked instances first, permission-blocked above idle. This turns "check on five terminals" into "answer the one that is actually blocking":
Web UI (optional, phase 2)
The daemon serves an HTTP + WebSocket endpoint. The UI is a dashboard of instance cards (status, attention, port links) with an embedded xterm.js terminal per instance, bridged to the same tmux session over a WebSocket. Port links are clickable, opening the forwarded 127.0.0.1:PORT directly.
State, reconciliation, and failure
State store
SQLite at ~/.claudio/state.db, WAL mode. Multi-writer in phase 1 (N CLI processes), single-writer once the daemon exists — see CLI-first, daemon-ready for why the concurrency design must assume the former.
The governing rule: SQLite stores intent; Docker stores reality. Anything Docker already knows authoritatively is derived at query time, never duplicated — two sources of truth drift.
instances — the intent record:
Note desired_state, not state. Whether the container is actually running is Docker's answer, read live. Storing observed state is what creates drift; storing desired state is what the reconciler compares against.
port_mappings — the reservations, and the one thing Docker genuinely cannot tell you:
This table is what earns SQLite in phase 1: host port ownership across instances is state nothing else tracks, and UNIQUE(host_port) is what makes concurrent allocation safe.
repos — reference-clone bookkeeping (mirror path, last fetch) for Fast provisioning. events — an append-only log of state transitions, provisioning failures with the underlying error text preserved, and port allocations; this is what lets claudio status explain why something failed rather than only that it did.
Deliberately not stored: container running/exited status, container IP, image digests, anything from docker inspect. claudio ls is "select instances → ask Docker about their containers → merge", which is correct by construction when a container is killed out-of-band, rather than depending on reconciliation to notice.
A schema_version table and numbered migrations exist from the first commit — this store outlives every container.
The reconciler resolves disagreements between the two:
- Container gone, DB says desired
running→ mark stopped, emit event, release proxy ports. - Container running, no DB row, but carrying Claudio labels → flag as untracked; never auto-adopt or auto-remove (below).
- Container running, no Claudio labels → ignore entirely; it belongs to something else.
- DB says PROVISIONING at startup → resume the provisioning state machine from
provision_step.
Untracked containers are surfaced, not resolved automatically. If the DB is lost, restored from backup, or edited out of band, a labelled container can outlive its instance row. Reconstructing it automatically risks resurrecting an instance the user deliberately discarded; removing it automatically destroys work. Both are recoverable, so claudio ls lists them in a separate section and offers the two explicit moves:
adopt reconstructs the row from labels and re-reserves the published ports; forget removes the container.
Every container is labelled (claudio.instance.id, claudio.repo, claudio.created_at) so state is recoverable from Docker alone if state.db is lost.
Remote Docker endpoints
The daemon talks to a Docker endpoint via DOCKER_HOST. Nothing in the design assumes it is local. This makes two later capabilities cheap: running instances on a beefier remote machine, and per-instance VM isolation via Lima/Colima. The one thing that does assume locality is the bind mount — a remote endpoint requires either a synced workspace or accepting volume-only workspaces. Flagged as a known limitation.
Failure modes
Implementation choices
Runtime detection
The engine's identity changes the correct mount strategy, so the CLI probes it via /info and records a runtime profile:
The profile selects mount defaults and gates features that depend on host-visible volumes. It is recorded on each instance, so an instance created under one runtime is not silently reused under another.
Two implementation findings, both empirical and both fixed in code before shipping:
-
Native Linux cannot be detected from the
OperatingSystemstring alone. A native Linux daemon reports its distro name —"Ubuntu 22.04.3 LTS","Debian GNU/Linux 12 (bookworm)"— and Ubuntu's own string does not contain the substring "linux" at all. The only reliable native-Linux signal is that the CLI process itself is running onGOOS=linuxwith neither VM marker present; the daemon's OS string cannot carry this distinction on its own. An unrecognized OS string reached from a non-Linux host (e.g. a remote Linux daemon dialed from macOS, see Remote Docker endpoints) isgeneric, notnative— those are different situations. -
The endpoint must be resolved through the
dockerCLI's current context, not left to the SDK's default. Verified on the development machine: the OS-level default socket (/var/run/docker.sock) was symlinked to Docker Desktop's socket, even thoughdocker infoand everydockercommand correctly used OrbStack — because thedockerCLI itself readscurrentContextfrom~/.docker/config.json, and the Docker Go SDK'sclient.FromEnvdoes not. Resolving the endpoint viadocker context inspectbefore falling back to the SDK's default is what makes detection agree with what the user's owndockercommands actually do; skipping this step silently misclassifiedorbstackasdocker-desktopon this exact machine.
Configuration layering
Configuration resolves in three layers, later winning:
The split is not arbitrary — it follows what each fact is about. Ports and services are properties of the project, so they belong in the repo and should be versioned and shared. Resource ceilings are properties of the machine, so their baseline is global: not every machine is the same, and not every repo has the same needs.
The global baseline applies to every new instance, whether created by cloning a repo or by claudio create --new on a fresh git init root. A repo that declares nothing simply inherits it.
A repo's request must be overridable locally. "This monorepo needs 12 GB to build" is worth committing; "my VM only has 15.7 GB" is a fact about the machine that has to win. Without the local layer, a repo committing a request larger than the available VM would make the instance unstartable with no recourse. When the local layer overrides a repo's request, create says so rather than silently ignoring it.
Config schema
Claudio defines its own schema rather than adopting devcontainer.json's. The deciding cases were concrete: forwardPorts is a bare integer array with nowhere to express a service name or expose: false for a container-internal port, and hostRequirements runs the opposite direction from what is needed here — it states a minimum the host must meet, where Claudio needs a ceiling the instance may not exceed, layered over a machine-level baseline. A schema shaped by a tool with one container per repo and no machine layer would have to be fought at every one of those points.
Conventions: snake_case throughout, no camelCase anywhere. Every list-shaped key is a list, never "string or list". Unknown keys are an error, not silently ignored — a typo in post_create should say so rather than quietly doing nothing.
<repo>/.claudio.yml — what the project needs
Versioned, shared, committed. Every key optional; a repo that declares nothing gets sensible defaults.
~/.claudio/config.yml — what the machine allows
resources means the same thing in both files, which is what makes the layering legible: the repo states a need, the machine states a limit, and the local layer settles it.
Interop
.devcontainer/devcontainer.json remains a first-class input. Where a repo has one it is read and translated into this schema (ROD-110), so a repo that already declares its image, ports, and setup needs no .claudio.yml at all. Translating at the boundary keeps the interoperability without importing the spec's constraints into the model.
The same applies to docker-compose.yml: a repo that ships one has already declared its services, and Claudio uses it directly rather than asking for the same facts twice.
CLI-first, daemon-ready
Phase 1 ships no daemon. A daemon earns its place with the reconciler, the port proxies, and the activity monitor — all phase 2+. Building one for phase 1 would mean a serialization round-trip to reach code in the same address space.
The layering is what keeps that a deferral rather than a rewrite:
Three rules make the migration cheap:
coreis I/O-free. Structs in, structs out; it never prints, reads stdin, or callsos.Exit. All user-facing output lives incmd/. This is the load-bearing constraint — violating it turns the daemon port into a rewrite.- Commands depend on the
Clientinterface, never oncoredirectly. Phase 1 wireslocal.Client(a thin pass-through); phase 2 wiresremote.Clientover the socket. Command files do not change.mainselects via a socket-existence check, so the daemon is purely additive. attachsits outside all of it — it resolves the container name andsyscall.Execs intodocker exec -it … tmux attach, in both phases. Replacing the process is what yields a genuine TTY with working resize and signal handling; any abstraction here costs fidelity for nothing.
Interface discipline required from day one, so the eventual wire format is not an afterthought: request/response types are plain serializable structs (no io.Reader fields, func values, channels, or *sql.Tx leaking through); errors carry a typed code, not just a string; every operation takes context.Context first; and progress is a caller-supplied sink rather than a print — phase 1 passes a terminal writer, phase 2 the HTTP response stream, with the same core signature.
Storage must be multi-process safe from the start. Phase 1 has N concurrent CLI processes writing one SQLite file; phase 2 has a single writer. Designing for the multi-process case now yields code that stays correct when the daemon arrives — the reverse assumption breaks the moment two claudio create calls race for a port. Hence WAL mode, a short busy timeout, BEGIN IMMEDIATE on every read-modify-write, and port allocation as one transaction (SELECT free → INSERT reservation → COMMIT, then bind()-probe, releasing on failure) rather than select-then-insert across two statements.
Drawbacks
The costs this design knowingly accepts:
- A container is not a VM. The sandbox stops accidents reliably and malice only partially; a kernel exploit escapes it. Hostile code needs a VM boundary, not this.
- One credential in every container (phase 1). An agent that reads it holds the user's subscription token, and revoking it kills every instance at once. The credential proxy is the fix, and it is not phase 1.
- Bind mounts are slower than named volumes — 7–15× on metadata operations, measured. Repos with very large dependency trees pay that cost, and the design declines the available workaround because the workaround's price is host visibility.
- No daemon in phase 1 means lazy convergence. Nothing watches the Docker event stream, so state catches up only on the next command that touches an instance. A container killed out-of-band is noticed late.
- Bind mounts assume a local Docker endpoint. Everything else in the design is endpoint-agnostic; the workspace mount is the one thing that is not, which constrains the remote-endpoint and per-instance-VM directions.
- Detection is a ranked guess. Ports inferred from framework defaults will sometimes be wrong; the mitigation is that every mapping records its source and is correctable, not that the guess is always right.
Rationale and alternatives
Why worktrees rather than a clone per instance
A clone per instance is the obvious model and it is slow on any large repo. Worktrees share one object store, so provisioning drops from minutes to seconds. The second benefit was not the motivation but matters more: git refuses to check out one branch in two worktrees, which makes "one instance = one line of work" structural rather than a convention agents can violate.
Why bind mounts rather than named volumes
Covered in full under Dependency directories. The short version: volumes are measurably much faster, and their cost is that the host cannot properly see the offloaded directory. Host access to the working tree is the reason this project exists, so the faster option is the one that breaks the premise.
Why tmux rather than a custom PTY multiplexer
Detach/reattach, multi-viewer, scrollback, and client-disconnect survival are exactly tmux's job, and each is a rich source of bugs when hand-rolled. Reimplementing it would be the single biggest source of defects in the project.
Why attach execs Docker directly rather than proxying through the daemon
Inserting a daemon hop between two TTYs adds latency and breaks window-resize propagation, and buys nothing. syscall.Exec replaces the process, which is what yields a genuine TTY with working resize and signals.
Go vs Rust
Rust was considered seriously; the call was roughly 60/40.
For Go: github.com/docker/docker/client is first-party — Docker's own daemon and CLI are written in Go, so the API types are the Go types. Rust's bollard is good and actively maintained, but it is a third-party reimplementation tracking someone else's API. For a tool whose entire job is orchestrating Docker, that asymmetry matters more than it usually would. Goroutines also fit the workload (N port proxies, an event subscriber, a reconciler loop) with less ceremony than Arc<Mutex<…>> plumbing.
For Rust: better error modelling for this domain — provisioning is a state machine with a dozen distinct failure modes, and Result + thiserror expresses "which of these went wrong" far more precisely than if err != nil chains, for a tool whose value depends on explaining failures clearly. clap is also ahead of anything in Go, and rusqlite is a nicer binding (with the cgo tradeoff reversed, since it bundles SQLite and still yields a static binary).
Both produce a single static binary and cross-compile cleanly; that is a wash. The decision rests on the first-party SDK, and on this being mostly coordination work — shelling to git, calling Docker, moving bytes between sockets — which is Go's sweet spot and is neither performance- nor memory-safety-critical.
Rejected: dynamic port binding by container recreation
Recreating the container when a new port appears is correct and disruptive — it kills the Claude Code session. A userspace TCP proxy on the host is ~100 lines and lets a port appear seconds after the agent starts a server, with no session loss. Recreation remains available as an explicit --recreate.
Rejected: Docker-in-Docker as the sidecar mechanism
Mounting the host Docker socket into an agent container is a direct host-root escalation path. Repos that genuinely need the agent to run docker compose get a rootless DinD sidecar, opt-in per instance, rather than the socket.
Rejected: adopting devcontainer.json as Claudio's own schema
Two concrete cases decided it. forwardPorts is a bare integer array with nowhere to express a service name or expose: false. And hostRequirements states a minimum the host must meet, where Claudio needs a ceiling the instance may not exceed over a machine baseline. Devcontainer files stay first-class inputs, translated at the boundary.
Rejected: per-instance Anthropic credentials
setup-token takes no arguments — no scope, no expiry, no label — and mints one long-lived token, so per-instance directories would hold N copies of the same secret while adding cleanup paths that leak credentials when missed.
Rejected: screen-scraping for activity state
Pattern-matching tmux capture-pane output breaks whenever the TUI changes. Claude Code hooks are a supported interface, fire in headless tmux with no client attached, and distinguish permission-blocked from idle — a distinction screen-scraping cannot reliably make.
Prior art
- Devcontainers (VS Code) — the closest neighbour, and the source of two borrowed techniques: build-arg UID/GID matching, and treating
devcontainer.jsonas a declaration of image, ports, and post-create commands. The divergence is scope: devcontainers assume one container per repo, one developer, and an editor in the loop, with no machine-level resource layer and no notion of N parallel sessions on the same repository. git worktree— the mechanism that makes one-clone-many-sessions work, including its branch-exclusivity rule, which this design leans on deliberately rather than working around.- tmux — session persistence, detach/reattach, and multi-client attach, adopted wholesale rather than reimplemented.
- Docker Compose — per-project namespacing (
-p) and override files, used directly for sidecar services rather than reinvented. - OrbStack's file sharing — host-visible volume paths (
~/OrbStack/docker/volumes/<name>) were the capability that made volume offloading look viable; measuring it is what showed the feature would not port to Docker Desktop.
Unresolved questions
Resolved items are kept with their resolutions, because the reasoning is the useful part.
-
Instance ↔ branch coupling.Resolved. An instance is bound to one branch at creation.--branchchecks out an existing branch,--new-branchcreates one, and passing neither generatesclaudio/<instance-id>. The generated default is what keeps "one instance = one line of work" true and stops parallel agents colliding on a branch by accident. Nothing prevents the agent from switching branches inside the container; the binding is a default and a naming convention, not an enforcement. -
Session persistence across container rebuild.Resolved.claudio restartresumes the existing session;--freshopts out. Resuming is the payoff for bind-mountinghome/at all — without it the mount buys nothing and containers are not really disposable. Contingent on verifying that Claude Code resumes reliably from persisted state in a new container (nothing session-critical outsidehome/); if it does not, the fallback is fresh-by-default with--resume, taken as an explicit decision rather than a silent degradation. -
Concurrent attach.Resolved by scope. Claudio is single-user and local-only — no outside traffic, no shared instances, no second operator. Multiple tmux clients therefore attach to the same session with a shared cursor, and that is correct: one person attaching twice is either deliberate or immediately obvious to them. No read-only mirror, no-rmode, no "someone else is attached" warning — all of that would be machinery for a situation that cannot arise. A future web UI is a full client on the same session, not a mirror.This scope also underwrites two decisions made elsewhere: binding published ports to
127.0.0.1rather than0.0.0.0, and using a Unix socket with file permissions as the only access control. There is no network exposure surface to reason about. -
Resource defaults.Resolved by measurement. Conservative, overridable defaults: 6 GB memory, 4 CPUs, 512 PIDs per instance. The measurement that mattered: the host has 36 GB, but OrbStack's VM is capped at 15.7 GB, and the VM cap is the budget containers actually share — sizing against host RAM would overcommit by more than 2×. All 12 CPUs pass through, so the CPU figure is deliberate overcommit on the basis that builds are bursty. Adaptive per-instance limits were rejected: a ceiling that changes when a sibling starts is confusing to reason about. -
Resolved. Claudio defines its own schema, rather than adopting or half-borrowing.claudio.ymlschema stability.devcontainer.json's. Two concrete cases decided it:forwardPortsis a bare integer array with nowhere to express a service name orexpose: false, andhostRequirementsstates a minimum the host must meet where Claudio needs a ceiling the instance may not exceed over a machine baseline — a spec built for one container per repo has no equivalent of that layering. Conventions:snake_casethroughout, every list-shaped key always a list, unknown keys an error rather than silently ignored..devcontainer.jsonanddocker-compose.ymlremain first-class inputs, translated at the boundary, so nothing already working is reinvented. -
Runtime-conditional behavior.Detecting the editing model.Both resolved by dropping volume offloading entirely. With dependency directories always bind-mounted there is no runtime-conditional mount path to support and no editing model to detect — the host always sees the full tree.
Still open:
- Remote endpoints and the workspace mount. A non-local Docker endpoint requires either a synced workspace or accepting volume-only workspaces. Neither has been designed; the limitation is recorded rather than solved.
- Claude Code resume fidelity across a new container. Item 2's resolution is contingent on it, and the fallback is specified, but the verification has not been done.
Future possibilities
Phasing, and what each phase unlocks.
Phase 1 — Core loop. create/ls/attach/destroy, bind-mounted clone, statically detected ports, SQLite state, tmux attach — plus compose sidecars. No daemon.
Sidecars are phase 1 rather than a later enhancement because most real repos need Postgres or Redis to run at all; an instance that cannot start the app it is sandboxing is not useful. The dividing line is that services run as sidecars on a per-instance network while client tools (psql, redis-cli) go in the agent image — the agent container holds the tools that talk to services, never the services themselves.
Phase 2 — Ergonomics. The daemon arrives here, because the port proxy needs a long-lived listening socket. Runtime port discovery, richer detection, the hook-based activity monitor, send, open, shared package caches, gc.
Phase 3 — Devcontainers. .devcontainer.json compatibility for repos that already declare image, ports, and setup commands.
Phase 4 — Surface and scale. Web UI, remote Docker endpoints, credential proxy with push approval, network egress policy, per-instance VM isolation.
Known deferral: with no daemon in phase 1, nothing watches the Docker event stream, so state converges lazily — on the next command that touches an instance. That is the gap the reconciler closes in phase 2, and it is deliberate.
Appendix A — Mount strategy evidence
All claims about mount strategy were verified on the development machine on 2026-09-05 rather than taken from documentation. Environment: OrbStack (server 29.4.0, client 28.3.2), macOS 26.5.2, Apple Silicon, 16 GB. Docker Desktop is installed but was not the active context — an easy misread, since docker --version reports the client.
Verified on OrbStack
~/OrbStack/docker/volumes/<name>exposes named volumes to macOS; confirmed by OrbStack's own~/OrbStack/README.txt.- Container→host, host→container, and both directions through a host symlink all round-trip correctly.
- A host symlink alone leaves
/workspace/node_modulesdangling inside the container (readlinkreturns the macOS path). The volume mount is required regardless. - Volume root is
0:0on creation; uid 1000 writes fail until it is chowned. - After chown to
1000:1000, the Mac user (uid 501) can still read and write through the host view — OrbStack does not enforce Linux ownership there. - Host volume listing is eventually consistent; a direct
statresolves a volume thatlshas not yet shown.
Verified on Docker Desktop
Mountpointpoints inside the VM and does not exist on macOS; all storage is one ~64 GBDocker.raw. Not traversable.
Verified for mount shadowing generally
mountinside the container shows two distinct filesystems (virtiofsfor the bind mount,btrfs/ext4for the volume).- The shadowed host directory retains its prior contents — it goes stale, not empty.
- An empty volume is seeded from the image layer only: 36 files over Alpine's
/etc, 0 files over a bind-mountednode_modules. lnacross the boundary fails withEXDEV: Cross-device link.inotifyevents propagate from host writes across the bind mount.
Benchmark methodology: 3000 small files, three runs each, comparing bind mount / named volume / container overlay. Corroborating published data: Mainardi's 2025 macOS Docker benchmarks (~2.5× on npm install; Docker-VZ 9.53 s vs 3.61 s hybrid).
Test volumes and directories were removed after measurement.
Primary sources
- VS Code — improve container performance
- VS Code — remote extension architecture (why the "harmless" caveat depends on workspace extensions)
- Docker — volumes
- Docker — Synchronized File Shares announcement
- Mainardi — Docker performance on macOS, 2025
- pnpm FAQ · pnpm#5318
- vscode-remote-release#3008 · #6669 (root-owned leftover directory)
- OrbStack file sharing:
~/OrbStack/README.txt, orb.cx/docker-mount
Appendix B — Git worktrees across the container boundary
Verified on the development machine, 2026-09-06, because the failure mode is silent and the fix is not obvious.
The problem. git worktree add writes a .git file (not a directory) into the worktree, containing an absolute path to administrative files that live under the main clone:
That path is outside the worktree, so a container mounting only the worktree cannot resolve it:
There is also a reverse pointer — main-clone/.git/worktrees/<name>/gitdir points back at the worktree's .git file — so any fix has to keep both ends consistent.
Four approaches tested:
The chosen arrangement. Rewrite both pointers as relative paths:
Then mount the repo root — not the worktree — with the worktree as the working directory:
Confirmed working end to end: host git status and git rev-parse --abbrev-ref HEAD both correct; inside the container the same commands report the same branch; a commit made inside the container appeared immediately in the host's git log.
Also verified:
- Git refuses to check out one branch in two worktrees —
fatal: 'claudio/brave-otter' is already checked out at '…'. This is what makes "one instance = one line of work" structural rather than conventional. - A UID mismatch triggers git's
safe.directoryprotection; build-arg UID matching is the primary defense, with asafe.directoryentry in the image as a backstop.