sandbox-cli
sandbox-cli
multi-agent

Run several agents at once, and know which of them worked

The unit never changes: one agent, one branch, one worktree, one container. Everything on this page adds something to that unit — a background container, a file describing several of them, a check that decides whether the work is done. None of it changes the boundary, because a fleet task becomes exactly the options a single --worktree run produces.

four rungs, one ladder

You can stop at any rung

These are not four features to choose between. Each one is the previous one plus a single addition, and most days the first is enough.

  1. 1

    One agent per branch

    --worktree feature-a

    A git worktree of its own, so two agents never edit the same files or fight over the same branch.

    Enough when: You are running one agent and watching it.

  2. 2

    In the background

    --detach

    The container outlives the terminal, so one window can start several.

    Enough when: You want two or three going and will check on them by hand.

  3. 3

    A fleet

    fleet run

    All of them from one file, plus the answer the rung above cannot give: which of these actually worked?

    Enough when: You want the work checked, not just started.

  4. 4

    Handing files over

    --share

    One directory two sandboxes can both see, for an artifact that crosses between them.

    Enough when: One agent produces something another needs.

~/projects/app

Your checkout, untouched and still on whatever branch you had. The worktrees live in a sandbox-owned directory, so the project folder stays clean.

~/.config/sandbox/worktrees/app-9f95/<branch>
  • feature-a-p "implement the API"
    sandbox-dk0gtrd15s2g
    mem 412MiBcpu 82%
  • feature-b-p "port the tests"
    sandbox-9f2la8hq4vzn
    mem 308MiBcpu 61%
  • docs/rewrite-p "rewrite the guide"
    sandbox-m4x1pq7bd0cs
    mem 196MiBcpu 24%
then, from your normal checkout:git diff main...feature-asandbox-cli worktree commit feature-a -m "…"sandbox-cli worktree rm feature-a
quick start

A fleet is one file and one command

Write a fleet.yaml next to your project. Every task gets its own branch, its own worktree and its own detached container; your checkout is never touched and never changes branch.

fleet.yaml
agent: claude # the default for tasks that name no agent
max_parallel: 2
defaults:
memory: 4g
cpus: "2"
git: true # so the agents' commits carry your name and email
 
tasks:
- branch: feature-login
prompt: Implement the login form in src/auth/. Add tests. Commit when they pass.
verify: go build ./... && go test ./...
 
- branch: feature-ratelimit
agent: codex # a different agent for this branch
memory: 8g # and its own limits
prompt: Add per-IP rate limiting to src/server/. Add tests. Commit when they pass.
verify: go test ./src/server/...

Then the whole cycle, all of it from your normal checkout. fleet run looks for fleet.yaml in the current directory; -f path names another.

  1. 1
    Terminal
    $sandbox-cli claude

    Log in once per agent. A detached container cannot answer a login prompt, so every agent the file names needs this first.

    …and again for each other agent: sandbox-cli codex

  2. 2
    Terminal
    $sandbox-cli fleet run --dry-run

    See what each task would do — the agent argv, the verify, the limits, the mounts — without launching anything.

  3. 3
    Terminal
    $sandbox-cli fleet run

    Fan out. One branch, one worktree and one container per task.

  4. 4
    Terminal
    $sandbox-cli fleet status

    One line per branch: which agent, whether it is running, how long, what it left uncommitted, how far ahead it is.

    --watch redraws until you stop it

  5. 5
    Terminal
    $sandbox-cli fleet logs feature-login

    What one agent actually said. Works after it exits, because fleet containers are kept.

    -f to follow it live

  6. 6
    Terminal
    $sandbox-cli fleet land --all

    Commit whatever each agent left, then merge every branch that can be merged, oldest first.

    or one at a time: sandbox-cli fleet land feature-login

  7. 7
    Terminal
    $sandbox-cli fleet clean --worktrees

    Reap the finished containers, and the checkouts too — skipping any with uncommitted work rather than discarding it.

When part of it goes wrong

You do not re-run the file. Commenting the other tasks out is the thing people reach for, and a fleet file with half its tasks commented out is one that will be run that way again by mistake.

Terminal
$sandbox-cli fleet run --only feature-login

Retry the one task that failed. A branch the file does not contain is an error listing the ones it does — launching nothing looks exactly like success.

Terminal
$sandbox-cli fleet run --resume

Pick up an interrupted run: skip branches whose agent is still working and branches that already exited 0, start the rest.

the file, and what is in reach

Passed by name, and read in a container that holds two directories

Two things account for most of the confusion a first fleet produces: the file is not found for you, and a task can see far less of your machine than the prompt writing it assumes.

Named, never discovered

Terminal
$sandbox-cli fleet run -f fleet.yml

fleet run reads exactly the path you give it, and defaults to ./fleet.yaml. It never walks up the directory tree looking for one, unlike .sandbox.yaml. That is what lets the file carry command-line trust: naming it is an act you performed, so a fleet.yaml in a parent directory or a repository you cloned can never be picked up by accident.

A typo is an error, not a default

Terminal
$line 1: field max_parallell not found in type fleet.Spec

Unknown keys are rejected rather than ignored. max_parallell: 4 fails the load, because the alternative is a fleet running with limits its author believed they had set — including the memory caps.

-f and -c are different files

Terminal
$sandbox-cli fleet run -f ci.yml -c ~/.config/sandbox/prod.yaml

-f is the task list. -c is the sandbox config, and it is where image:, mounts: and env_allow: live — a task does not choose what it runs in, so a fleet file has no key for any of them.

What a task can actually see

An agent asked for something that is not mounted does not invent it — it reports the path as missing, which reads as the agent failing rather than as the prompt naming somewhere it was never going to reach.

The worktree, the login, and the repository's .git

/workspace is the worktree and /sandbox/home is the agent's persisted login. A worktree task also gets the parent repository's .git — read-write, mounted at its own host path, because a linked worktree cannot commit without it — with .git/hooks read-only over the top. Nothing else on your machine is there: not /tmp, not your other repositories, not the skills your own Claude Code has. An agent told to write outside those paths writes into a container that is about to be thrown away.

Skills travel with the repository, not with you

A skill committed at .claude/skills/ arrives inside /workspace and every task sees it. One installed at ~/.claude/skills/ on the host does not — the container's HOME is a different directory entirely. It has to be committed, too: a worktree is a checkout of the branch, so an untracked skill in your main checkout is invisible to it.

The handover directory is opt-in

/shared exists only when you pass --share, and a fleet file cannot turn it on. Without the flag an agent asked to read /shared/x is being asked for a path that is not there, which it will report as missing rather than invent.

The image is not the fleet's to choose

The base image carries node and python3 and no other toolchain — no pip, no go, no compiler beyond build-essential. A verify: of go test or pip install exits 127 in it. The fix is a config layer rather than a fleet one: image: in your own config, or -c naming one.

watching one work

A fleet container has no keyboard

Deliberately, and it is not the same as being unreachable. Nothing is attached to a background container, so an agent that stopped to ask a question would not fail — it would hang, holding a slot until somebody noticed.

You read it, you do not type at it

Terminal
$sandbox-cli fleet logs feature-login -f

A fleet container is created with no tty and no stdin, so there is nothing to answer a question with. That is the point: a fleet is unattended, and an agent that stopped to ask would not fail but hang, holding a max_parallel slot. To watch one live, sandbox-cli attach <branch> — the top-level command, since there is no fleet attach — streams its output and says in as many words that it has no keyboard.

Silence is usually not a hang

Terminal
$sandbox-cli fleet status --watch

An agent in headless mode buffers and prints its answer at the end rather than narrating as it goes, so an empty log a minute in is normal. fleet status is the better progress signal: it reports what each branch has produced — commits, uncommitted files, how far ahead it is — rather than waiting on stdout.

To talk to one, run it interactively instead

Terminal
$sandbox-cli fleet stop feature-login
$sandbox-cli claude --worktree feature-login

Same worktree, same branch, attached. Stop the fleet task first: nothing refuses this for you. The duplicate-name rule that enforces one agent per branch only applies to detached containers, and a foreground run is named for the moment it started — so two agents will happily edit one worktree, which is the silent loss that rule exists to prevent everywhere else.

mixing agents

Claude on one branch, Codex on another

agent: at the top of the file is the default. A task that names its own overrides it — so you can put two agents on the same problem and compare what comes back, or use whichever is better at each job.

fleet.yaml
agent: claude
tasks:
- branch: feature-login
prompt: Implement the login form.
 
- branch: feature-ratelimit
agent: codex
prompt: Add per-IP rate limiting.

The fleet-wide agent: becomes optional once every task names one. Mixing costs nothing at the boundary — each agent gets exactly the container it would get on its own. What it does cost is setup: every agent you name needs its own login before the run, because none of them can answer a login prompt from a detached container. sandbox-cli fleet run --dry-run prints a reminder when it sees a mixed file.

Which agents are eligible

A fleet starts every agent detached, so an agent may only appear in a file if it has a verified headless mode — a way to run a prompt to completion without ever asking a human anything. An agent that stops for approval in a fleet does not fail; it hangs until you notice, holding a slot.

agent:What the fleet runsDelivery
clinecline PROMPT --auto-approve true

The prompt is a bare positional and the TUI is the opt-in (-i), which is the inverse of the others. --auto-approve is passed explicitly rather than relying on its default, because an unattended run that starts asking does not fail — it hangs.

on first use
claudeclaude -p PROMPT --dangerously-skip-permissionsin the image
codexcodex exec PROMPT

Codex applies its own approval policy on top; relax it through the task's args:.

in the image
geminigemini --yolo -p PROMPT

-p alone runs to completion and then stops at a tool it wants confirmed, so --yolo is not optional here.

in the image
opencodeopencode run PROMPTin the image

Anything else is rejected when the file is parsed, before a single container starts. The other 9 adapters are perfectly usable interactively — they are simply not ones we have confirmed will never stop and wait. Adding one to this list means running it and recording the argv, which a test pins, so it cannot grow by guesswork.

Per-task limits

A task may also raise its own memory, cpus and allow, for the one branch that needs a bigger build or one more domain. The two rules are not symmetric, on purpose: memory and cpus replace the fleet-wide value, and allow adds to it. A task that could subtract from the allowlist would be asking for less egress than the file's author wrote a line above it; the way to want that is to move the domain onto the tasks that need it.

the point of the whole thing

verify: is what makes a run autonomous rather than merely unattended

Without it, a fleet is a fan-out with a nicer status table: every agent succeeds the moment it stops talking — including the one that confidently did nothing, and the one that deleted the failing test.

How it runs

The command runs inside the container, after the agent, and its exit code becomes the container's. Inside because a check running on your host would be host code selected by a file the agent can write. After the agent, whatever the agent's own exit code was — an agent that exits non-zero having left a tree that builds and tests clean has done the job, and one that exits 0 having deleted the test file has not. The task's definition of done gets the last word.

What it cannot do

The command is fixed, but its meaning is not: go test ./... runs agent-written tests over an agent-written tree, so an agent that deletes the failing test passes. This makes forging a pass require editing the tests rather than merely claiming success — a real improvement over an exit code alone, and not the same thing as being unforgeable.

Exit 90 means the agent finished and its verify said no. A task with no verify still runs — this is a fleet of agents, not a CI system — but it lands reported as unverified rather than passed, because nothing checked it.

landing the work

The only command that writes to your base branch

fleet land commits whatever the agent left in its worktree, then merges the branch (--no-ff) into the one you have checked out. It refuses rather than guessing, and --all sorts those refusals into two kinds.

It refuses when…Under --allWhy
The agent is still runningskips this branchIts next action could change what you just merged.
The work failed its verifyskips this branchNothing has said this work is right. --force lands it anyway, and says so.
There is nothing to mergeskips this branchNo commits beyond the base, so a merge would be of zero commits.
Your checkout moved since launchstops the runEach container records the branch its work was meant for. Landing onto a branch nobody chose needs a rewrite to undo. --onto says you mean it.
The base checkout is dirtystops the runThe merge commit would sweep up your unrelated in-progress work.
An agent is working in the base checkoutstops the runThe merge rewrites files under it, mid-edit.
The merge conflictsstops the runIt stops with git's own message and leaves the merge in place. land never resolves anything itself.

The split is the design, not a convenience. A problem with the branch skips it and the rest carry on. A problem with the branch being merged into stops there, because it will be just as wrong for the next branch and landing more on top of it makes it harder to undo. What already landed is printed either way — those merges are commits in your branch whatever happens next.

handing files between agents

A convention, deliberately not a protocol

Two sandboxes are blind to each other by design. When one agent produces something another needs — an API contract, a schema, a generated client — --share gives them one directory in common. Files in a shared directory, or nothing: there is no messaging protocol here and none is planned.

fleet.yaml
tasks:
- branch: api-contract
prompt: |
Design the API for the new billing flow and write it to
/shared/billing/openapi.yaml. Do not implement anything.
verify: test -s /shared/billing/openapi.yaml
 
- branch: api-client
prompt: |
Read /shared/billing/openapi.yaml and implement a typed client for it
in src/api/. If the file is not there, stop and say so.
verify: go build ./...

Turn it on from the command line

sandbox-cli fleet run --share. A cross-project directory is exactly the reach the sandbox otherwise refuses, so it is a flag and not a fleet.yaml key — switching it on stays something you can see in your shell history.

Order with max_parallel: 1

Tasks start in file order, so one slot means the producer finishes before the consumer starts. There is no depends_on: and there will not be one — a dependency graph is the beginning of a workflow engine, and this is a CLI.

Say what to do when the file is missing

An agent that invents the API rather than stopping is the failure mode here, and the consumer's verify is what catches it.

Two agents that need to coordinate step by step are one task, not two.

guardrails

The parts that refuse before you find out the hard way

Each of these exists because the failure it prevents is silent, expensive, or both.

One agent per branch

Enforced by construction, not by a check: a detached container is named sandbox-<repo>-<branch>, and docker refuses a duplicate name. Two agents in one checkout lose work silently.

The fleet has to fit in the machine

Before anything starts, sandbox-cli multiplies how many agents run at once by the widest per-task memory cap and compares it with what the host has. Too big and it refuses, naming the arithmetic. It is the concurrent count, not the task count — twenty tasks at max_parallel: 2 is two agents' worth.

A fleet never touches your own session

fleet stop --all does not reach an interactive --detach session in the same repository, fleet clean does not reap one, and max_parallel does not count one. sandbox-cli list marks which is which, because that is where you decide what to kill.

A fleet agent is a session

fleet status prints the same id sandbox-cli list does, and logs, attach and kill all take a branch name — so there is one way to reach a running agent whatever started it.

Run it under --profile prod

dev warns when a control cannot be satisfied; prod refuses. Nobody is watching a fleet, so a warning goes into a log no one reads. prod also declines to mount the persisted login, so each agent needs its key in the environment instead.