Install
box needs Linux with KVM (or macOS with a podman machine), podman, and crun built with libkrun. The firecracker backend downloads a pinned Firecracker and guest kernel on first use and verifies their SHA-256.
curl -fsSL https://chxperiments.github.io/box/install.sh | sh
box doctor # checks podman, KVM, krun, subordinate UIDs; prints a fix for each failure
Quick start
box new agent --from tiny-python # a ~60 MB Python sandbox box build agent # builds, then proves it has its own kernel box run agent -- python3 -c 'print(6*7)' box up agent # keep one VM running box exec agent -- pip list box down agent
For agents, add isolation: strict to the Boxfile. The tiny-python, tiny-node and ai-agent examples already have it.
Boxfile
One YAML file per sandbox at ~/.box/sandboxes/<name>/Boxfile. Unknown keys are rejected, so a typo fails loudly.
| Field | Values | Default | Meaning |
|---|---|---|---|
| base | image reference | alpine:latest | The image every VM starts from. |
| backend | podman, krun, firecracker | podman | What boots the VM. See Architecture. |
| isolation | standard, strict | standard | strict runs the VMM as a subordinate UID. |
| cpus | 1 to 16 | 2 | vCPUs. |
| ram_mib | 128 and up | 2048 | Guest RAM in MiB. |
| network | bridge, none | bridge | Internet access, or none at all. |
| readonly | bool | false | Read-only root; /tmp and /data stay writable. |
| timeout_seconds | int | 0 | Per-command limit; exits 124. 0 is unlimited. |
| warm | 0 to 8 | 0 | VMs kept booted for run (podman, krun). |
| warmup | shell lines | Run once in each warm VM before it serves a run. | |
| packages | names | Installed with the image's package manager. | |
| run | shell lines | Extra build steps. | |
| env | map | Environment for every command. | |
| mounts | host, guest, mode | Host directories shared in (podman, krun); ro by default. | |
| blueprint | users, write_files, runcmd | Provisioning applied at build time. | |
| pkgmgr | apk, apt, dnf | inferred | Override the package manager. |
| passt | bool | false | A real NIC with a default route (podman, krun). |
| seccomp | path | A seccomp profile for the VMM. |
CLI
| Command | What it does |
|---|---|
| box new <name> [--from example] | Create a sandbox, optionally from a shipped example |
| box build <name> | Build the image and prove the sandbox has its own kernel |
| box run <name> -- <cmd> | Run one command in a fresh VM |
| box up / down <name> | Keep a VM running, or stop it |
| box exec <name> -- <cmd> | Run in the running VM; state carries over |
| box shell <name> | Interactive session |
| box fork <name> <fork> | Branch /data |
| box diff / apply / discard <fork> | Review, merge or drop a fork's changes |
| box data import / export <name> <dir> | Move files into or out of /data |
| box snapshot / restore <name> [label] | Archive /data and roll back |
| box reset <name> | Empty /data |
| box serve | The local API the SDKs use |
| box doctor | Check the host setup, with a fix for each failure |
| box ls, env, logs, verify | Inspect sandboxes |
| box rename, destroy, nuke | Remove or rename |
SDKs
Python, TypeScript, Go and Rust. Each talks to box serve over a Unix socket only your user can open, and starts it when nothing is listening. Pick a language once; every sample on this page follows.
pip install ./sdk/python # from a box checkout
from sdbox import Client, Sandboxnpm install ./sdk/typescript // from a box checkout
import { Client, Sandbox } from "box-sdk";go get github.com/chxperiments/box/sdk/go import "github.com/chxperiments/box/sdk/go"
cargo add --git https://github.com/chxperiments/box sdbox
use sdbox::{Client, Command, Sandbox};
up and down
Boot a VM once and keep it running for exec. The context-manager forms bring it down afterwards unless it was already up.
sb = Sandbox("agent")
sb.up()
...
sb.down()
# or
with Sandbox("agent") as sb:
...const sb = new Sandbox("agent");
await sb.up();
...
await sb.down();
// or
await sb.withUp(async (sb) => {
...
});sb := box.New().Sandbox("agent")
if err := sb.Up(ctx); err != nil {
return err
}
defer sb.Down(ctx)let sb = Sandbox::new("agent")?;
sb.up()?;
...
sb.down()?;
exec and run
exec runs in the running VM, so state carries over. run takes a fresh VM and throws it away. A command is a shell string or an argv list; a timeout exits 124.
r = sb.exec(["python3", "-c", "print(6*7)"])
r = sb.exec("ls -la | head", timeout=10)
r = sb.exec("wc -c", stdin=b"bytes")
r = Sandbox("agent").run("pytest -q")let r = await sb.exec(["python3", "-c", "print(6*7)"]);
r = await sb.exec("ls -la | head", { timeout: 10 });
r = await sb.exec("wc -c", { stdin: Buffer.from("bytes") });
r = await new Sandbox("agent").run("pytest -q");r, err := sb.Exec(ctx, box.Cmd("python3", "-c", "print(6*7)"))
r, err = sb.Exec(ctx, box.Command{
Argv: []string{"sh", "-c", "ls -la | head"}, Timeout: 10 * time.Second})
r, err = sb.Run(ctx, box.Sh("pytest -q"))let r = sb.exec(Command::new(["python3", "-c", "print(6*7)"]))?;
let r = sb.exec(Command::sh("ls -la | head").timeout(10))?;
let r = sb.exec(Command::new(["wc", "-c"]).stdin(b"bytes".to_vec()))?;
let r = Sandbox::new("agent")?.run("pytest -q")?;
Files
Reads and writes happen inside the guest, so a path or symlink the sandbox controls can never redirect them onto a host file. For bulk moves, use box data import and export.
sb.write_file("/data/task.py", "print(1)")
data = sb.read_file("/data/result.json")await sb.writeFile("/data/task.py", "print(1)");
const data = await sb.readFile("/data/result.json");err := sb.WriteFile(ctx, "/data/task.py", []byte("print(1)"))
data, err := sb.ReadFile(ctx, "/data/result.json")sb.write_file("/data/task.py", "print(1)")?;
let data = sb.read_file("/data/result.json")?;
Forks: fork, diff, apply, discard
A fork has its parent's image and a /data overlaid on the parent's. Running it never touches the parent. diff lists changes as A added, M modified, D deleted; apply merges them into the parent; discard drops them. Apply is refused while either side is up.
trial = Sandbox("agent").fork("trial")
trial.run("python3 refactor.py").check()
for ch in trial.diff():
print(ch.kind, ch.path)
if tests_pass:
trial.apply() # returns how many changes were merged
else:
trial.discard()const trial = await new Sandbox("agent").fork("trial");
(await trial.run("python3 refactor.py")).check();
for (const ch of await trial.diff()) console.log(ch.kind, ch.path);
if (testsPass) await trial.apply();
else await trial.discard();trial, err := box.New().Sandbox("agent").Fork(ctx, "trial")
trial.Run(ctx, box.Sh("python3 refactor.py"))
changes, _ := trial.Diff(ctx)
for _, ch := range changes {
fmt.Println(ch.Kind, ch.Path)
}
if testsPass {
trial.Apply(ctx)
} else {
trial.Discard(ctx)
}let trial = Sandbox::new("agent")?.fork("trial")?;
trial.run("python3 refactor.py")?.check()?;
for ch in trial.diff()? {
println!("{} {}", ch.kind, ch.path);
}
if tests_pass { trial.apply()?; } else { trial.discard()?; }
Results and errors
A non-zero exit is a result, not an exception. Errors carry a machine-readable code: no_sandbox, not_up, bad_name, bad_request, no_server.
r = sb.exec("make test")
r.exit_code, r.stdout, r.stderr, r.duration_ms, r.timed_out
r.ok; r.stdout_text
r.check() # raises CommandFailed on a non-zero exit
from sdbox import NotFound, NotUp, BoxErrorconst r = await sb.exec("make test");
r.exitCode; r.stdout; r.stderr; r.durationMs; r.timedOut;
r.ok; r.stdoutText;
r.check(); // throws CommandFailed on a non-zero exit
import { NotFound, NotUp, BoxError } from "box-sdk";r, err := sb.Exec(ctx, box.Sh("make test"))
r.ExitCode; r.Stdout; r.Stderr; r.Duration; r.TimedOut
r.OK()
box.IsNotFound(err); box.IsNotUp(err)let r = sb.exec("make test")?;
r.exit_code; r.stdout; r.stderr; r.duration_ms; r.timed_out;
r.ok(); r.stdout_text();
let r = r.check()?; // Error::CommandFailed on a non-zero exit
match err { Error::NotFound(_) | Error::NotUp(_) => {}, _ => {} }
MCP for agents
box mcp serves box over the Model Context Protocol on stdin and stdout, so an agent in Claude Code, Claude Desktop, Cursor or any MCP client can run code in your sandboxes. Every tool goes through the same handler as the local API, with the same checks. Sandboxes are still created and built by you, on the CLI.
claude mcp add box -- box mcp
{ "mcpServers": { "box": { "command": "box", "args": ["mcp"] } } }
| Tool | What it does |
|---|---|
| list_sandboxes | The sandboxes the agent may use |
| run | A command in a fresh VM |
| up / exec / down | Keep a VM and run commands in it, state carried over |
| read_file / write_file | File I/O inside the guest |
| fork / diff / discard | Branch /data, review the changes, drop them |
apply is not offered to agents unless you start the server with --allow-apply. A fork exists so that you review an agent's work before it reaches real data; an agent that could apply its own fork would skip that.
Local API
HTTP with JSON bodies on ~/.box/box.sock, owner-only. stdout, stderr and stdin are base64. The SDKs are thin clients of this.
| Method | Path | Body or result |
|---|---|---|
| GET | /v1/version | |
| GET | /v1/sandboxes | name, up, warm, warm_target, network, base |
| POST | /v1/sandboxes/{name}/up | |
| POST | /v1/sandboxes/{name}/down | |
| POST | /v1/sandboxes/{name}/exec | argv, stdin (base64), timeout_seconds |
| POST | /v1/sandboxes/{name}/run | same, in a fresh VM |
| POST | /v1/sandboxes/{name}/fork | as |
| GET | /v1/sandboxes/{name}/diff | returns changes: [kind, path] |
| POST | /v1/sandboxes/{name}/apply | returns changes: n |
| POST | /v1/sandboxes/{name}/discard | returns changes: n |