Boxes runs AI coding-agent sessions in isolated Docker containers and gives you a mobile-friendly web UI to drive them. Each session is a long-lived container with its own workspace volume, its own internal network, and no route out except through a proxy that vets every destination.
Agent turns keep running when your browser goes away. The orchestrator — not the browser — is the agent's client of record, so you can lock your phone mid-task and find the finished thread when you come back.
- Isolated sessions. One container, one network, one workspace per session. Non-root, read-only rootfs, no capabilities, no host mounts, no published ports.
- Vetted egress only. Session networks are
internal; the sole way out is an egress proxy that rejects private addresses, pins the connection to a vetted IP, and can be given a host allowlist. - No credentials in the sandbox. Sessions hold placeholder tokens. The proxy swaps in the real ones on the wire, and refuses any other credential to those hosts — so a leaked placeholder is worth nothing.
- One service, one port. The orchestrator serves the UI, the REST API and
the WebSocket gateway on
:3000. No second origin, nothing to configure. - Claude Code today, other agents later — sessions speak the Agent Client Protocol (ACP).
ARCHITECTURE.md describes how it is built.
- Docker with Compose v2, on Linux or macOS
- A Claude token from
claude setup-token(subscription-based, inference only) - Node 22+ — only if you want to develop on Boxes itself
git clone https://github.com/splitbrain/experiments.git boxes
cd boxes
docker build -t boxes-session:latest session-image/
docker compose up -dThe session image is deliberately not part of compose.yaml — the
orchestrator creates session containers at runtime. Build it once as shown,
and rebuild it whenever session-image/ changes.
Boxes is now on http://localhost:3000, bound to loopback because it ships with no authentication of its own. See Behind a reverse proxy before moving it.
There is no required configuration — every setting has a working default. To
change one, copy .env.example to .env and edit it. To keep the file out of
the repo, point at it instead:
BOXES_ENV=~/.config/boxes.env docker compose up -dTo actually run an agent turn you need one credential:
claude setup-token # then export it, or put it in ./.env
export PROFILE_DEFAULT_CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
docker compose up -dThe two credentials are the only settings compose takes from the shell, so
they need never be written down. That is also why they are the only two a
BOXES_ENV file cannot carry — use the shell or ./.env for those, and the
env file for everything else.
Without it, sessions still start and the UI still works — only inference fails. Alternatively skip the variable and log in inside a session, which keeps the credential in that session's home volume and out of every file:
docker exec -it session-<id> claude /login| Variable | Default | What |
|---|---|---|
BIND_ADDR |
127.0.0.1 |
Interface the port is published on |
HOST_PORT |
3000 |
Published port |
PROFILE_DEFAULT_CLAUDE_CODE_OAUTH_TOKEN |
— | Claude token; without it no turn can run |
PROFILE_DEFAULT_GH_TOKEN |
— | Classic GitHub PAT for the bot account |
PROFILE_DEFAULT_GIT_NAME |
boxes-bot |
Git author name in sessions |
PROFILE_DEFAULT_GIT_EMAIL |
boxes-bot@users.noreply.github.com |
Git author email |
WS_AUTH_TOKEN |
generated | Gateway bearer token; generated on first boot into /data/ws-auth-token and reused |
SESSION_IMAGE |
boxes-session:latest |
Image sessions run |
SESSION_SUBNET_POOL |
10.200.0.0/16 |
Pool sessions get a /24 from |
SESSION_MEM_LIMIT |
4g |
Per-session memory cap |
SESSION_CPUS |
2 |
Per-session CPU cap |
SESSION_PIDS_LIMIT |
512 |
Per-session pids cap |
IDLE_STOP_MINUTES |
30 |
Idle time before a session container is stopped |
PERMISSION_FALLBACK |
hold |
hold or deny for an unanswered permission request |
PERMISSION_HOLD_MINUTES |
120 |
How long before that fallback applies |
NTFY_URL |
— | POSTed when a permission request is waiting |
DATA_DIR |
/data |
Database and generated token, inside the volume |
EGRESS_PROXY_CONTAINER |
boxes-egress-proxy |
Proxy container the orchestrator attaches to session networks |
EGRESS_ALLOWED_HOSTS |
— | Hosts sessions may reach; empty means every public host |
Everything is parsed and validated at boot, so a bad value fails startup
rather than surfacing later. orchestrator/src/config.ts is the full list.
docker compose up -d # start
docker compose logs -f # follow
docker compose down # stopHealth check and verification:
curl localhost:3000/healthz
API_BASE=http://localhost:3000 ./scripts/smoke-test.shThe smoke test needs no credentials: it creates throwaway sessions, asserts the isolation properties from inside a container, and cleans up. Run it after any change to networking, the proxy, or the session image. Where the deployment has configured credentials or an allowlist, it additionally proves that no real credential is inside a session and that the allowlist bites.
Open http://localhost:3000.
Create a session. Give it a name. The session gets its own container, network and volumes, and its workspace starts empty — tell the agent what to fetch into it.
Talk to the agent. Tap a session card to open its thread. That is the whole interface: type, and the turn runs in the container. Close the tab or lock your phone whenever you like; reattaching replays the thread from the session's own history. Opening a stopped session starts it again.
Run a shell command with !. A composer line starting with ! runs as
bash -lc in the session container and never reaches the model — no tokens
spent, no chance of it being read as an instruction:
!npm test
!git diff --stat
Output is printed as it arrives, as a code block ending with the exit code. Commands are capped at 120 seconds and 256 KiB of output.
Answer permission requests. When the agent asks to do something requiring
consent, the request goes to your attached browser. With nobody attached it is
queued (and posted to NTFY_URL if set) and delivered to the next browser to
attach. Nothing is ever auto-approved.
Manage the session. The ⓘ corner of a card opens its details and controls: start, stop, delete, the container and network names, and the WebSocket URL and bearer token for attaching your own ACP client. Deleting removes the volumes too, so the agent's work and the thread history go with it.
Idle sessions — no turn, no waiting request, no attached browser — are stopped
after IDLE_STOP_MINUTES. They are never deleted.
Boxes has no authentication and holds the Docker socket, so as shipped it binds
to 127.0.0.1. Anything beyond a single-user machine needs a reverse proxy in
front — Caddy, nginx, Traefik, whatever you already run. Proxy to
127.0.0.1:3000, or join the boxes_default network and use
orchestrator:3000 if the proxy is itself a container. Only widen BIND_ADDR
once something else is doing the authenticating.
Two rules:
- Authenticate
/and/api, and terminate TLS there. Every route is otherwise open, including the one that creates containers. - Do not put HTTP authentication in front of
/ws. Browsers cannot attach Basic credentials to a WebSocket upgrade, so guarding it breaks every thread view. It does not need guarding: the gateway authenticates the upgrade itself againstWS_AUTH_TOKEN.
Also forward Upgrade and Connection on /ws, and give it a long read
timeout — a turn can hold the socket open for minutes with nothing on it.
Two settings shape it, and both default to something safe.
The allowlist. EGRESS_ALLOWED_HOSTS is a comma or whitespace separated
list of exact hostnames and one-label wildcards:
EGRESS_ALLOWED_HOSTS=github.com,*.github.com,*.githubusercontent.com,api.anthropic.com,registry.npmjs.org
*.example.com matches a.example.com, but neither example.com nor
a.b.example.com. An address literal matches only as a literal. Leave it unset
and behaviour is what it has always been: any public host, private ranges still
denied. The hosts of a credential you configured are always reachable, so a
narrow list can never sever inference or GitHub.
Token translation. It is always on, and it applies to whichever credentials you configured:
- Set
PROFILE_DEFAULT_CLAUDE_CODE_OAUTH_TOKEN, andapi.anthropic.combecomes a translated host. - Set
PROFILE_DEFAULT_GH_TOKEN, and the GitHub hosts do. - Leave one unset and its host stays an ordinary tunnel, which is what keeps
the "log in inside a session with
claude setup-token" flow working.
For a translated host the session holds a placeholder of the same shape as the real token, and:
docker exec session-<id> env | grep -c sk-ant-oat01-... # 0
The proxy terminates TLS for that host under a CA generated once for your deployment, swaps the placeholder for the real credential, and refuses any other credential outright — so "api.anthropic.com is allowlisted" no longer implies "any Anthropic account is reachable". Everything else stays an opaque tunnel the proxy cannot read.
The real credentials live in the orchestrator's environment and in the proxy's memory, and nowhere else. The proxy has no config file, no database and no CA on disk: it boots empty and is handed its policy over an authenticated channel on the compose network, which no session can route to. Restart it and the orchestrator's reconciler pushes again within a minute.
The CA certificate reaches each session as BOXES_PROXY_CA, written by the
entrypoint to ~/.boxes/proxy-ca.crt, with NODE_EXTRA_CA_CERTS,
SSL_CERT_FILE, GIT_SSL_CAINFO and CURL_CA_BUNDLE pointing at it.
A tool that honours none of those variables fails TLS against translated hosts only, which is a confusing shape — everything else keeps working. The fix is to point that tool at the same file:
| Tool | Variable | Notes |
|---|---|---|
| node, npm, anything on Node | NODE_EXTRA_CA_CERTS |
Already set |
| curl | CURL_CA_BUNDLE |
Already set |
| git | GIT_SSL_CAINFO |
Already set |
| gh, and most Go tools | SSL_CERT_FILE |
Already set |
Python requests |
REQUESTS_CA_BUNDLE |
Add it, pointing at $BOXES_PROXY_CA's file |
Python httpx, aiohttp |
SSL_CERT_FILE |
Already set |
| Deno | DENO_CERT |
Add it |
Rust reqwest (rustls) |
SSL_CERT_FILE |
Already set |
Rotating the CA is deleting egress-secrets.json from the data volume and
restarting; sessions created before that keep the old certificate and must be
recreated.
Isolation rests on two Docker primitives, with nothing touching the host firewall:
- Session networks are
internal. No NAT, no default route: no L3 path to your LAN, the internet, or another session. - The egress proxy is the only way out. It checks the allowlist, resolves the target, rejects the request if any resolved address is private, then connects to that specific vetted address without re-resolving — which is what closes DNS rebinding. Every connection it makes goes through that one check, including the ones it makes on behalf of a translated host. If the proxy is down, sessions have no egress at all.
- Credentials never enter the sandbox. See What a session can reach.
Session containers additionally run as a non-root user with ReadonlyRootfs,
CapDrop: ALL, no-new-privileges, a tmpfs /tmp, and memory, CPU and pids
limits.
Known residual risks, accepted deliberately:
- Host services bound to
0.0.0.0stay reachable from inside a session at the host's per-bridge IP; Docker's internal-network isolation filters forwarded traffic only. Anything sensitive on the host must have its own auth. - The orchestrator holds the Docker socket, which is root-equivalent. It is
mitigated by a fixed container template that user input never reaches and by
never shell-executing user strings — but the auth you put in front of
/apiis what keeps it yours. GET /api/sessionsreturnsWS_AUTH_TOKEN, behind that same auth.- A compromised proxy sees the credentials it injects. That is true of any injecting proxy; what this one adds is that it leaves nothing at rest.
- A credential you do not configure is not translated. A session that logs
itself in with
claude setup-tokenholds its own token, and prompt injection can leak that one. - Sibling sessions share a deployment's placeholders, so they map to the same real credentials. Per-session placeholders arrive with per-session credentials.
- Protocol behaviour is pinned to
claude-agent-acp0.70.0. Re-check capabilities and WebSocket framing on upgrade.
One toolchain across the repository: every package builds with Vite, tests with
Vitest, and type-checks before its Docker bundle, so an image cannot be built
from code that fails tsc.
cd orchestrator && npm run check && npm test
cd proxy && npm run check && npm test
cd dashboard && npm run check && npm test && npm run buildnpm run dev in dashboard/ serves the SPA with /api, /healthz and /ws
proxied to an orchestrator on port 3000. The dashboard's tests run in two
projects: unit for the framework-free stores, and e2e, which builds the
production bundle and drives it in Chromium against stub backends.
/playground renders every message part kind over a canned store, and the
browser suite asserts that page.
scripts/live-test.sh covers what only real inference can prove — a turn
surviving the browser leaving, thread replay on reattach, a permission request
held with nobody watching — and needs
PROFILE_DEFAULT_CLAUDE_CODE_OAUTH_TOKEN.
Tailwind utilities and shadcn/assistant-ui components only: no inline style=,
no CSS-in-JS, and design tokens defined once in src/globals.css — including
the @theme inline bridge, without which Tailwind silently drops every token
utility.
Components under src/components/assistant-ui/ and src/components/ui/ are
installed by their official CLIs and committed. Our edits carry a comment
saying so. Upgrade by re-running the CLI and reading the diff:
cd dashboard
npx assistant-ui add thread --overwrite # or: npx shadcn add <name> --overwrite
npm run check && npm test| Path | What |
|---|---|
orchestrator/ |
Node 22 + TypeScript: REST API, SQLite, Docker lifecycle, ACP gateway, idle reaper |
dashboard/ |
React SPA — session list and chat — built into the orchestrator image and served at / |
proxy/ |
The egress proxy: allowlist, address vetting and token translation — the security boundary |
session-image/ |
The per-session container image |
shared/types.ts |
REST shapes imported by both orchestrator and dashboard |
scripts/smoke-test.sh |
Security smoke test, no credentials needed |
scripts/live-test.sh |
The checks that need a real Claude token |
ARCHITECTURE.md |
How the system is put together |