sandbox-cli
sandbox-cli
TypeScriptPython
typescript sdk

The same boundary, driven from a program

Everything the CLI does to keep an agent inside a container happens on the machine running the daemon. This package is the way to ask for it from code — a run is a container, the worktree is what persists, and the outcome tells you what actually happened rather than what you asked for.

three nouns

A Studio is a daemon, a Project is a repository it has been told about, and a Workspaceis a branch's worktree inside one. They are the daemon's words, not the package's: borrowing “sandbox” from platforms whose sandbox is a machine you keep would promise something no endpoint here delivers.

It holds no docker socket and shells out to nothing. Adding a capability here means the daemon grows an endpoint, so the check that stands between an agent and your home directory is written once, in Go, with a test — rather than once per client that hopes to be careful.

before it works

Three steps, and only the last two are this package

The client talks to a daemon; it does not start one, and it cannot. Without the first step, Studio.connect() fails with “cannot reach the sandbox daemon”, which reads as a broken library rather than as a daemon nobody started.

  1. 01

    From the repository you want to work in, start the control plane

    Terminal — the machine that will run the containers
    $cd ~/code/my-app
    $curl -fsSL https://raw.githubusercontent.com/Amitgb14/sandbox-cli/main/studio.sh | sh

    It installs sandbox-cli and the daemon if they are missing, starts both halves, and registers the repository it was run in — which is what gives studio.project("my-app") something to find. It also writes the API port and a generated token into ~/.config/sandbox/studio, which is what lets Studio.connect() take no arguments. Docker is the one thing it will not install for you: the daemon holds its socket, and every run is a container.

  2. 02

    Then add the client to your own project

    Terminal — wherever your code lives
    $npm install @sandbox-cli/sdk

    Node 20 or newer, and this half needs nothing else: no docker socket, no binaries, nothing to configure. The package is the client only — the daemon from step one is what holds the socket and starts the containers.

  3. 03

    Write your script as an ES module

    agent.mts
    $import { Studio } from "@sandbox-cli/sdk";
     
    $const studio = await Studio.connect();
    $for (const p of await studio.projects()) console.log(p.id, p.name);

    Everything here uses top-level await, which needs an ES module: either "type": "module" in your package.json, or the .mts extension as above. Without one, tsx compiles the file as CommonJS and stops at "Top-level await is currently not supported" — a fact about your project rather than about the client.

  4. 04

    Run it

    Terminal — wherever your code lives
    $npx tsx agent.mts

    tsx runs TypeScript directly, so there is no build step for a script. Node 20 or newer runs the same file as JavaScript if you would rather: rename it .mjs and drop the types. What you should see is the daemon answering — the repositories it knows about, or a typed error naming which half is wrong.

using it

Zero configuration, then five lines

Every snippet below is from the package's README and its tests rather than written for this page.

  1. 01

    Connect to the daemon you already have

    import { Studio } from "@sandbox-cli/sdk";
     
    const studio = await Studio.connect(); // no arguments

    studio.sh writes the API port and a generated token into ~/.config/sandbox/studio, so a script on that machine has no reason to ask you for either. Explicit arguments win, then SANDBOX_API_URL and SANDBOX_STUDIO_TOKEN, then those files. Connecting makes one round trip to /v1/health, which is the only route that answers without a token — so a missing credential is reported as a missing credential rather than as a failure of whatever you ran first.

    You should see: A Studio bound to http://127.0.0.1:<the port studio.sh is using>, or a typed error saying which half is wrong.

  2. 02

    Pick a repository, and a branch to work in

    const repo = await studio.project(); // or ("my-app"), or a path
    const ws = await repo.workspace("agent-42");

    A Project is a repository the daemon has been told about — by id, by name when only one repository has it, by path, or with no argument at all, which asks git which repository the current directory belongs to — the same question the daemon asks, so a linked worktree resolves to its main repository rather than to itself. That last form is a lookup rather than a shortcut: the root is matched against what the daemon already knows, so a directory nobody registered is refused and told which roots exist. Run the script from anywhere, including a machine that is not the daemon's — in which case a local path correctly finds nothing there. A Workspace is that branch's git worktree, created if it is not there, and it is the isolation unit: two agents in one tree is a data race with a filesystem in the middle.

  3. 03

    Run something, and get back what happened

    await ws.run(["npm", "ci"]);
     
    const tests = await ws.run(["npm", "test"], {
    env: { CI: "true" },
    timeoutMs: 10 * 60_000,
    });
     
    console.log(tests.exitCode, tests.stdout, tests.stderr);

    Each run is its own container over that worktree, and the worktree is what persists: the second command finds node_modules because the first wrote it to disk, not because anything stayed alive. stdout and stderr come back separated, and the exit code is the container's.

    You should see: The command's real exit code — 0, or whatever it actually returned.

  4. 04

    Or hand the work to an agent

    const done = await ws.agent("claude", "make the failing test pass", {
    fallback: ["codex"],
    });
     
    if (done.routedFrom) {
    console.warn(`${done.routedFrom} was down; ${done.agent} did the work`);
    }

    Every outcome carries agent, routedFrom and routeReason whether or not you ask for them. A script that cannot see a failover attributes one agent's work to another — under the wrong login, and the wrong bill.

  5. 05

    Follow a run while it happens

    for await (const event of ws.follow(run.id)) {
    if (event.type === "log") process.stdout.write(event.data + "\n");
    }

    Server-sent events, because the daemon offers SSE and WebSocket carrying the identical payload and SSE needs nothing a Node runtime does not already have. The loop ends on the daemon's own end event; leaving early closes the connection, which is what stops the docker logs --follow behind it.

small examples

Eight things worth doing on the first day

Each is a whole script rather than a fragment, because the first thing anybody does with a new client is paste one and run it.

What repositories does this daemon know?

agent.mts
const studio = await Studio.connect();
for (const p of await studio.projects()) {
console.log(p.id, p.name, p.root);
}

Names come from the daemon's registry — what somebody added in Studio, plus the repository it was started in.

Work on a repository the daemon has never heard of

agent.mts
const repo = await studio.addProject(); // this script's own repository
// ...or a path on the daemon's machine:
// const repo = await studio.addProject("/home/you/code/api");
// ...or a directory that is not a repository yet:
// const repo = await studio.addProject(undefined, { init: true });
 
const ws = await repo.workspace("agent-1");
console.log(await ws.run(["git", "log", "--oneline", "-1"]));

A path is resolved on the machine running the script, then sent absolute — so the no-argument form is for a daemon on this machine, and a remote one will say it has no such directory rather than guess. Adding a repository that is already registered returns the same row, so this is safe to run every time. Not a repository yet? Pass { init: true } to run git init first — and commit something, because Studio works from committed state and a repository with no commits makes empty worktrees.

Run one command and read its output

agent.mts
const repo = await studio.project("your-repo");
const ws = await repo.workspace("scratch");
 
const out = await ws.run(["sh", "-c", "ls -la; git status --short"]);
console.log(out.exitCode, out.stdout, out.stderr);

A workspace is a branch's worktree, created if it is not there. The container mounts that tree at /workspace and nothing else of yours.

Run the same thing twice without them colliding

agent.mts
for (const name of ["one", "two"]) {
const ws = await repo.workspace(`try-${name}`);
console.log(await ws.run(["sh", "-c", `echo ${name}`]));
}

A branch per run. Docker refuses a duplicate container name, which is what stops two agents sharing one checkout — so two runs on one branch collide, and two branches do not. Both keep their logs.

Hand a task to an agent

agent.mts
const done = await ws.agent("claude", "add a test for the parser", {
fallback: ["codex"],
timeoutMs: 15 * 60_000,
});
 
if (done.routedFrom) console.warn(`${done.routedFrom} was down; ${done.agent} did it`);
console.log(done.exitCode, done.stopped);

The agent needs a login inside the sandbox first — run it once interactively with `sandbox-cli claude` on that machine. routedFrom is how you notice a fallback fired; stopped is how you tell an interrupted run from a failed one.

Watch a long run as it happens

agent.mts
const run = await ws.start({ agent: "claude", prompt: "explain this repository" });
 
for await (const event of ws.follow(run.id)) {
if (event.type === "log") console.log(event.data);
}

start() launches without waiting; follow() streams until the daemon says the output has ended. Leaving the loop early closes the stream, which is what stops the log tail behind it.

Checkpoint before something risky, and roll back if it goes wrong

agent.mts
import { NothingToSnapshotError } from "@sandbox-cli/sdk";
 
const before = await ws.snapshot({ label: "before the migration" });
 
const out = await ws.agent("claude", "migrate the schema");
if (out.exitCode !== 0) {
const back = await ws.restore(before.id); // a new branch at the snapshot
console.log(`its starting point is on ${back.branch}`);
}

A snapshot is a commit of the working tree under refs/sandbox/snapshots/, written through a private index — your own index, HEAD, branches and working tree are never touched. It holds files and nothing else, so it is not a way to resume a stopped machine. Branch mode is the default and the only one that cannot destroy anything; mode: "worktree" puts the files back in place, and is refused on a dirty tree. An unchanged tree throws NothingToSnapshotError rather than handing back an id that points at no commit.

Keep a checkpoint off the machine

agent.mts
const before = await ws.snapshot({ label: "before the migration" });
 
if (!before.remote?.uploaded) {
// Real and local-only: the snapshot was taken, the copy did not happen.
console.warn("no off-machine copy:", before.remote?.error ?? "no bucket configured");
}
 
// Later, once the network is back, or the bucket exists:
await ws.uploadSnapshot(before.id);
await ws.verifySnapshot(before.id); // ask the bucket, rather than trusting the record

With a bucket configured (snapshot.s3), a snapshot is also uploaded as a git bundle — a packfile git alone can read on a machine that has never seen the repository, not an archive that needs this tool. sandbox-cli never holds the key: access_key_env names the environment variable it is read from, so there is nowhere in a config file, a settings file or an API response for a secret to be. A capture whose upload fails still returns the snapshot, because the checkpoint is real and only the copy failed.

Clean up when you are done with a run

agent.mts
await ws.stop(run.id); // ask it to exit
await ws.remove(run.id); // then discard the container and its logs

Two calls, and neither happens for you: a finished run's logs are the evidence for what it did. remove() is also what frees the branch's name for the next run.

a box that is not this one

Point it at a Linux machine, with a URL and a token

The containers run where the daemon runs, so a beefy Linux box is the whole point. A script needs two values from it and nothing else — no tunnel, and none of the CORS or Host flags the browser needs, because those checks fire on an Origin header that a browser sends and a script does not.

  1. 01

    On the Linux box: start the daemon, bound to an address your machine can reach

    Terminal — the Linux box
    $cd ~/code/your-repo
    $curl -fsSL https://raw.githubusercontent.com/Amitgb14/sandbox-cli/main/studio.sh -o studio.sh
    $sh studio.sh up --api-only --bind 10.0.0.5

    --api-only starts the daemon without the browser half, which a script does not need. --bind is the address to dial; the daemon refuses a routable one without a token, so the script generates one and prints it. It also tells the daemon to answer to that name — it answers to loopback names by default and refuses everything else, which looks exactly like the daemon being down.

  2. 02

    It prints the two values your script needs

    What it prints
    On the machine with the browser, open Studio → Settings → Connection:
    Daemon URL http://10.0.0.5:8787
    Token 3f9c1e7a…

    The token belongs to that machine, not to you: every daemon generates its own. Copy both. `sh studio.sh status` prints them again later.

  3. 03

    Open the port, and check it before you touch any code

    Terminal — the Linux box
    $sudo firewall-cmd --permanent --add-port=8787/tcp && sudo firewall-cmd --reload
    # Debian and Ubuntu: sudo ufw allow from 10.0.0.0/24 to any port 8787 proto tcp

    A server distribution denies inbound by default, and the failure is silent from the other side. Check it with curl from your own machine: /v1/health is the one route that answers without a token, so a JSON reply means the network, the bind and the firewall are all correct and anything left is authentication.

  4. 04

    In your script: the URL and the token, and nothing else

    agent.mts
    import { Studio } from "@sandbox-cli/sdk";
     
    const studio = await Studio.connect({
    url: "http://10.0.0.5:8787",
    token: process.env.SANDBOX_STUDIO_TOKEN,
    });
     
    console.log(await studio.health());

    Keep the token in the environment rather than in the file — it is a credential for a machine that can start containers. No CORS origin and no Host flag are involved: those checks fire on an Origin header, which browsers send and scripts do not, so a script is governed by the token alone.

There is no TLS yet. On a bound address the token and everything it protects cross the network in cleartext, so this is for a network you already trust. For anything wider, put a reverse proxy in front and dial its name — the daemon needs -allow-host for that name, and your script changes by one string.

a whole script

Install, hand the work to an agent, run the tests

Everything above in one file — bounded, and checking each claim the outcome makes. It is examples/agent-run.ts in the package, compiled by its test run, so an example that stopped matching the API fails a build rather than misleading somebody who typed it.

examples/agent-run.ts
import { Studio, WaitError, type Outcome } from "@sandbox-cli/sdk";
 
const studio = await Studio.connect(); // port and token from ~/.config/sandbox/studio
const repo = await studio.project("my-app");
const ws = await repo.workspace("agent-42"); // a git worktree on that branch
 
try {
// npm rather than pnpm: the base image is node:22-bookworm-slim and carries
// npm only, so an example reaching for pnpm would exit 127 on its first line —
// in a script whose whole claim is that it was checked.
const install = await ws.run(["npm", "ci"], { timeoutMs: 10 * 60_000 });
if (install.exitCode !== 0) {
console.error(install.stderr);
// `process.exitCode` rather than `process.exit()`: writes to a pipe — CI
// logs, `| tee`, a parent capturing output — are asynchronous, and exiting
// discards whatever is still buffered. That truncates hardest on the runs
// with the most to say.
process.exitCode = install.exitCode;
throw new Error("install failed");
}
 
const fix: Outcome = await ws.agent("claude", "make the failing test pass", {
fallback: ["codex"],
timeoutMs: 20 * 60_000,
});
 
// Reported on every outcome, not on request: a script that cannot see the
// failover credits the wrong agent — and bills the wrong account.
if (fix.routedFrom) {
console.warn(`${fix.routedFrom} was unavailable — ${fix.agent} did the work`);
}
// A stopped run is not a failed one. The exit code of a container somebody
// interrupted is not a verdict on the work.
if (fix.stopped) {
console.error(`${fix.agent} outlived its deadline and was stopped`);
process.exitCode = 1;
throw new Error("the agent was stopped");
}
// The verdict itself, which `stopped` is not: an agent that exited non-zero
// has finished and failed. Falling through to the tests would blame them for
// work the agent never completed.
if (fix.exitCode !== 0) {
console.error(fix.stderr);
process.exitCode = fix.exitCode;
throw new Error(`${fix.agent} exited ${fix.exitCode}`);
}
 
// node_modules survived the first container because it was written to the
// worktree, not because anything stayed alive.
const tests = await ws.run(["npm", "test"], { env: { CI: "true" } });
console.log(tests.stdout);
process.exitCode = tests.exitCode;
} catch (err) {
// The launch succeeded and the wait did not, so the container is still out
// there holding this branch's name — which docker will not let anything else
// take until it is gone.
if (err instanceof WaitError) await ws.stop(err.run.id);
throw err;
}

The second command finds node_modules because the first wrote it to the worktree, not because a process stayed alive. Each run is its own container.

routedFrom is checked because a fallback is invisible otherwise: the work gets done by an agent you did not name, under its login and its bill.

stopped is checked separately from the exit code, and WaitErrorcarries the run — a container that outlived its deadline is still holding the branch's name until something stops it.

many at once

A workflow, without writing an agent

Three tasks, three branches, three containers, in parallel — then one gate deciding which of them is worth a human's attention. The orchestration is Promise.all and an if: the only model involved is the one working inside each container. It is examples/workflow.ts, compiled by the same test run as the script above.

examples/workflow.ts
import { Studio, WaitError, type Outcome } from "@sandbox-cli/sdk";
 
/**
* A workflow, without writing an agent.
*
* Three tasks, three branches, three containers, in parallel — then one gate
* that decides which of them a human should look at. The orchestration is
* ordinary TypeScript: `Promise.all`, an array, an `if`. Nothing here needs a
* model to decide what happens next, which is the point — a workflow whose
* control flow is code fails the same way twice, and one whose control flow is a
* prompt does not.
*/
 
/** The agent asked for first. `fallback` names who covers an outage. */
const PRIMARY = "claude";
 
const TASKS = [
{ branch: "wf-tests", prompt: "make the failing unit tests pass" },
{ branch: "wf-types", prompt: "remove every `any` in src/, keeping behaviour identical" },
{ branch: "wf-docs", prompt: "update README.md so the examples match the current API" },
];
 
const studio = await Studio.connect();
const repo = await studio.project(); // the repository this script is standing in
 
/** What the gate needs to know about one task, and nothing else. */
type Result = {
branch: string;
agent: string;
changed: boolean;
verified: boolean;
note: string;
};
 
async function attempt(task: (typeof TASKS)[number]): Promise<Result> {
const ws = await repo.workspace(task.branch);
// A finished run holds the branch's container name — docker refuses a
// duplicate, which is exactly what stops two agents sharing one checkout. This
// clears yesterday's corpse and nothing that is running.
await ws.clearFinished();
 
try {
const fix: Outcome = await ws.agent(PRIMARY, task.prompt, {
fallback: ["codex"],
timeoutMs: 20 * 60_000,
});
// What actually ran, which is not always what was asked for. The field is
// absent only when the daemon did not say — and it says whenever anything
// other than the primary did the work, so falling back to PRIMARY here never
// credits the wrong agent.
const agent = fix.agent ?? PRIMARY;
if (fix.stopped) {
// Not a verdict: a container somebody interrupted has no opinion about the
// work. Reporting it as a failure is how a deadline becomes a bug report.
return { branch: task.branch, agent, changed: false, verified: false, note: "outlived its deadline" };
}
if (fix.exitCode !== 0) {
// `||` rather than `??`: an empty stderr splits to [""], which is not
// nullish, so a nullish fallback never fires — and an agent that failed
// silently is exactly when the exit code is the only thing worth printing.
return { branch: task.branch, agent, changed: false, verified: false, note: fix.stderr.trim().split("\n").at(-1) || `exit ${fix.exitCode}` };
}
 
// Did it actually change anything? Asked of git rather than of the agent:
// an agent that reports success having written nothing is the commonest
// failure this gate exists to catch, and the one it cannot be told about.
const diff = await ws.run(["git", "status", "--porcelain"]);
const changed = diff.stdout.trim() !== "";
 
// The verification runs in the sandbox too. On the host it would be host
// code selected by files the agent just wrote.
const tests = await ws.run(["npm", "test"], { env: { CI: "true" }, timeoutMs: 10 * 60_000 });
 
return {
branch: task.branch,
agent,
changed,
verified: tests.exitCode === 0,
note: tests.exitCode === 0 ? "tests pass" : `tests exit ${tests.exitCode}`,
};
} catch (err) {
// The launch succeeded and the wait did not, so a container is still out
// there holding this branch's name. Nothing else can take it until it is
// gone — including the next run of this script.
if (err instanceof WaitError) await ws.stop(err.run.id);
throw err;
}
}
 
// In parallel, because the isolation unit is the branch: one worktree, one
// container, one agent. Two agents in one tree would be a data race with a
// filesystem in the middle; three agents in three trees are simply three runs.
const settled = await Promise.allSettled(TASKS.map(attempt));
 
const results = settled.map((s, i) =>
s.status === "fulfilled"
? s.value
: { branch: TASKS[i].branch, agent: "?", changed: false, verified: false, note: String(s.reason) },
);
 
for (const r of results) {
const mark = r.verified && r.changed ? "READY" : "SKIP ";
console.log(`${mark} ${r.branch.padEnd(10)} ${r.agent.padEnd(7)} ${r.note}`);
}
 
// The gate. A branch is worth a human's attention when the agent changed
// something *and* the tests agree — the two halves catch different lies, and
// either one alone has been enough to waste a review.
const ready = results.filter((r) => r.changed && r.verified);
console.log(`\n${ready.length}/${results.length} ready to review:`);
for (const r of ready) console.log(` sandbox-cli worktree git ${r.branch} -- diff`);
 
// Non-zero when nothing came out of it, so this can be the last line of a CI job
// without a wrapper deciding what "worked" meant.
process.exitCode = ready.length > 0 ? 0 : 1;
 

Parallel because the isolation unit is the branch: one worktree, one container, one agent. Two agents in one tree is a data race with a filesystem in the middle; three agents in three trees are simply three runs.

The gate asks git whether anything changed, rather than the agent. An agent reporting success having written nothing is the commonest thing this catches — and the one it cannot be told about.

The verification runs in the sandbox. On the host it would be host code selected by files the agent had just written.

agents that need each other

Passing work between agents

Two specialists research in parallel and a coordinator combines what they produced. The interesting part is not the fan-out but the gap in the middle: each agent has its own worktree, so the coordinator cannot see what the others wrote. Artifacts cross through the host, deliberately. It is examples/travel-planner.ts.

examples/travel-planner.ts
import { Studio, WaitError, type Outcome, type Workspace } from "@sandbox-cli/sdk";
 
/**
* Three agents that hand work to each other.
*
* Two specialists research in parallel — one flights, one hotels — and a
* coordinator combines what they produced. It is the shape most "multi-agent"
* workflows actually want, and the interesting part is not the fan-out but what
* happens between the two halves.
*
* Each agent works in its own branch's worktree, because that is the isolation
* unit: one tree, one container, one agent. Which means the coordinator **cannot
* see** what the specialists wrote — different trees, and this SDK has no file
* API to reach into one. So the artifacts cross deliberately, through the host,
* by reading stdout from one workspace and writing it into another. Two `run`
* calls and no new machinery.
*
* The alternative — telling the coordinator to "assume the files exist" — is
* worth naming because it is the natural thing to write and it fails silently:
* the agent invents plausible inputs and the report reads exactly like one built
* from the real ones.
*/
 
const TRIP = {
origin: "SFO",
destination: "NRT", // Tokyo
depart: "2026-10-15",
return: "2026-10-22",
adults: 2,
budgetUsd: 2500,
};
 
const BRIEF = `# Trip Brief
- Origin: ${TRIP.origin}
- Destination: ${TRIP.destination}
- Depart: ${TRIP.depart}
- Return: ${TRIP.return}
- Travelers: ${TRIP.adults} adults
- Rough total budget: $${TRIP.budgetUsd}
- Preferences: nonstop or one stop, mid-range hotels near transit, flexible on airline
`;
 
const studio = await Studio.connect();
// The repository this script is in. `{ init: true }` would `git init` a
// directory that is not one yet — opt-in, because the path belongs to the
// daemon's machine and `git init` would run on this one. Studio works from
// committed state either way: a repository with no commits makes empty
// worktrees, and addProject refuses that rather than letting an agent start in
// a /workspace with none of your files in it.
const repo = await studio.addProject();
const AGENT = "claude";
 
/** Write a file into a workspace from the host.
*
* Base64 rather than a heredoc, and that is the one detail in this file worth
* copying. An artifact written by an agent is attacker-controlled as far as this
* script is concerned, and a heredoc built by string interpolation is one `EOF`
* line away from being the next command — in a container, as root's entrypoint
* would see it. Base64 has no shell metacharacters, so nothing in the content
* can change what runs. (It travels in the argv, so this is for artifacts rather
* than for large files.) */
async function put(ws: Workspace, path: string, content: string): Promise<void> {
const b64 = Buffer.from(content, "utf8").toString("base64");
const res = await ws.run(["sh", "-c", `printf %s '${b64}' | base64 -d > ${path}`]);
if (res.exitCode !== 0) throw new Error(`writing ${path}: ${res.stderr.trim()}`);
}
 
/** Read a file out of a workspace. Empty when it is not there — an agent that
* did not produce its artifact is a fact the coordinator should be told.
*
* Base64 on the way back too, and not for symmetry: `Outcome.stdout` is the
* run's log *lines* joined with newlines, so a file's trailing newline cannot
* survive `cat` — measured, 64 bytes back for 65 written. Fine for reading
* output, wrong for moving a file, and the difference is one byte that no test
* of "did it work" would notice. */
async function get(ws: Workspace, path: string): Promise<string> {
const res = await ws.run(["sh", "-c", `base64 < ${path} 2>/dev/null | tr -d '\\n' || true`]);
return Buffer.from(res.stdout.trim(), "base64").toString("utf8");
}
 
/** One specialist: its own branch, its own container, its own conversation. */
async function specialist(branch: string, prompt: string): Promise<{ ws: Workspace; out: Outcome }> {
const ws = await repo.workspace(branch);
// A finished run keeps its branch's container name until something reaps it,
// so without this the second run of this script is refused with a 409.
await ws.clearFinished();
await put(ws, "trip-brief.md", BRIEF);
try {
const out = await ws.agent(AGENT, prompt, {
timeoutMs: 12 * 60_000,
fallback: ["codex"],
// Egress is whatever the daemon's posture allows — under the default
// allowlist these agents work from what the model already knows rather
// than from a live API. `allow: ["api.example.com"]` widens it for one
// run, and can only ever add to the daemon's list, never loosen it.
});
return { ws, out };
} catch (err) {
// The launch succeeded and the wait did not, so a container is still out
// there holding this branch's name — nothing else can take it, including
// the next run of this script.
if (err instanceof WaitError) await ws.stop(err.run.id);
throw err;
}
}
 
const flightPrompt = `You are the flight search specialist. Read trip-brief.md.
 
Research realistic options from ${TRIP.origin} to ${TRIP.destination} for those dates,
preferring nonstop or one stop. Write flights.json:
 
{"search": {...}, "options": [{"id": "F1", "airline": "...", "price_usd": 850,
"duration": "11h 20m", "stops": 0, "notes": "..."}], "recommended": "F1"}
 
Be realistic about 2026 prices. Write the file and stop.`;
 
const hotelPrompt = `You are the hotel search specialist. Read trip-brief.md.
 
Find three or four mid-range hotels in Tokyo near transit, $120-250 a night.
Write hotels.json:
 
{"city": "Tokyo", "check_in": "${TRIP.depart}", "check_out": "${TRIP.return}",
"options": [{"id": "H1", "name": "...", "area": "...", "price_per_night_usd": 180,
"total_estimate_usd": 1260, "rating": 4.5, "pros": ["..."], "cons": ["..."]}],
"recommended": "H1"}
 
Write the file and stop.`;
 
// In parallel: two branches, two containers, two agents. allSettled rather than
// all, because one specialist failing is not a reason to lose the other's work —
// the coordinator is told what is missing instead.
const [flights, hotels] = await Promise.allSettled([
specialist("agent-flights", flightPrompt),
specialist("agent-hotels", hotelPrompt),
]);
 
for (const [what, r] of [["flights", flights], ["hotels", hotels]] as const) {
if (r.status === "rejected") console.error(`${what}: ${r.reason}`);
else console.log(`${what}: ${r.value.out.agent ?? AGENT} exited ${r.value.out.exitCode}`);
}
 
// The handover. Read each artifact out of the tree that produced it, and write
// it into the coordinator's — the step that makes this a workflow rather than
// three agents guessing in parallel.
const coord = await repo.workspace("agent-coordinator");
await coord.clearFinished();
await put(coord, "trip-brief.md", BRIEF);
 
const artifacts: string[] = [];
for (const [name, r] of [["flights.json", flights], ["hotels.json", hotels]] as const) {
const content = r.status === "fulfilled" ? await get(r.value.ws, name) : "";
if (content.trim() === "") continue;
await put(coord, name, content);
artifacts.push(name);
}
console.log(`handed over: ${artifacts.join(", ") || "nothing"}`);
 
const final = await coord.agent(
AGENT,
`You are the travel coordinator. You have trip-brief.md${
artifacts.length ? ` and ${artifacts.join(" and ")}` : ""
}.
 
${artifacts.length === 2
? "Pick the best flight and hotel combination that stays near the budget."
: "Some specialist output is missing. Say so explicitly in the itinerary and work with what is here — do not invent the missing file's contents."}
 
Write itinerary.md for a human to read, and recommendation.json:
 
{"flight_id": "F1", "hotel_id": "H1", "estimated_total_usd": 2100,
"summary": "...", "next_steps": ["..."]}
 
Any booking step is SIMULATED. Do not attempt a real payment or purchase.`,
{ timeoutMs: 10 * 60_000, fallback: ["codex"] },
);
 
console.log(`\ncoordinator exited ${final.exitCode}`);
console.log((await get(coord, "itinerary.md")).slice(0, 2000));
 
// The coordinator's verdict is the script's, so this can be the last line of a
// job without a wrapper deciding what "worked" meant.
process.exitCode = final.exitCode;
 

Files cross base64-encoded, not through a heredoc. An artifact written by an agent is attacker-controlled, and an interpolated heredoc is one EOF line away from being the next command.

Reads are base64 too, because stdout is the run's log lines joined — a file's trailing newline cannot survive cat. Measured: 64 bytes back for 65 written.

A specialist that produced nothing is named as missing, not quietly skipped. Telling the coordinator to assume the file exists is the natural thing to write, and it fails silently — the agent invents plausible inputs and the report reads exactly like a real one.

what it promises

Seven claims you can check

Each of these is a decision with a reason, and most of them are enforced by a test rather than by intent.

It is a client, and only a client

No docker socket, no shelling out to sandbox-cli, no argv assembled here. Every gate that makes a sandbox a sandbox — the workspace refusals, the fake HOME, default-deny environment, the egress allowlist — is applied where the container is built, on the machine running the daemon. When this package wants a capability the daemon does not expose, the daemon grows an endpoint and the gate is written once, in Go, with a test.

Finding a repository is a lookup, never a registration

studio.project() with no argument walks up to the git root and matches it against the repositories the daemon has been told about. What it will not do is add the one it fails to find: the registry is the list of directories that daemon will touch, and a lookup that quietly grew it would turn a typo into a permanent entry. studio.addProject() is the sentence that asks — the only call that hands over a path, mirroring the one endpoint that accepts one, where every check on a directory is applied by the daemon. It is a no-op for a repository already registered, so it is safe on every start.

There is no mock mode

A fake run() returning exitCode 0 is the worst possible default for a library whose entire job is telling you what happened. A test double belongs in your test suite, where you can see it.

A deadline stops the run, and says so

The wait is bounded — thirty minutes by default. When it expires the run is stopped and the outcome reports stopped: true, rather than putting a verdict on a container that was interrupted. If the stop itself is refused, that surfaces: claiming a run was stopped while it is still running would announce the outcome the deadline exists to prevent as though it had been prevented.

A launched run is never lost

If anything goes wrong after the launch — a daemon restart mid-poll, a cancel — you get a WaitError carrying the run. The container exists whatever happened, and a detached run holds sandbox-<repo>-<branch>, which docker will not duplicate, so an error without the id would leave the branch blocked by something you cannot name.

stop and remove are different, and neither is implicit

A finished run's logs are the evidence for what it did. Tidying up on the way out would discard that on every happy path, so nothing is removed unless you ask.

Running it twice needs you to say so

Docker refuses a duplicate container name, and that refusal is what enforces one agent per branch — so a finished run keeps its branch's name until somebody reaps it, and a second launch is refused with the run id and how to read its logs. Removing it for you would discard the evidence for what the first run did, on every second run. `{ replaceFinished: true }` says the evidence is spent; `clearFinished()` reaps it and tells you what went. Both refuse a run that is still going.

The types are generated, not written

src/contract.ts comes from internal/studioapi/types.go, the same pass that writes the documentation mirror, and CI fails when the checked-in copy differs from what the generator produces. A published client describing an API the daemon does not have is the failure that would be hardest to notice.

when it fails

Five failures, five different next steps

Collapsing these into one “request failed” is what sends a reader to the network when the answer was a timeout on their own side.

ErrorWhat it means
ApiErrorthe daemon refused, and its own message is carried verbatim
ConnectionErrornothing answered at that address
TimeoutErrorit answered too slowly — reachable, not down
WaitErrorthe run started; waiting for it did not finish. Carries the run
AbortErroryou cancelled it — err.name, the check callers already write