/workspace, a resource ceiling, and a clock.
How a sandbox moves
POST /sandbox/create returns immediately with status creating; the sandbox reads running
until it is stopped, deleted, or its clock runs out — the full set is
creating → running → terminating → terminated | error. Everything below is a call against
the sandbox id the create call hands back.
1
Create the sandbox
org_id is the one required field; defaults cover the rest (cpus "2", memory "2Gi",
timeout_seconds 600). isolation picks the boundary — container (plain runc),
gvisor (user-space kernel, the right default for agents), or microvm (hardware isolation
for code you trust least). env injects environment variables; session_id is your own
correlation handle, auto-generated if omitted.SANDBOX_ID, then poll the lightweight status route until it reads running — or subscribe to the
sandbox.ready webhook and skip the loop:For a shape you provision repeatedly,
POST /sandbox/create-from-template takes a
template_id plus optional timeout_seconds, env, override_cpus and override_memory.
GET /sandbox/{sandboxId} adds org_id, pod_ip and created_at to what status returns.2
Execute a first command
POST /sandbox/{sandboxId}/execute returns exit_code, stdout and stderr when the
command exits. command is a shell string or an argv array; working_dir defaults to
/workspace; env adds per-command variables on top of the sandbox’s own."stream": true and read the response as Server-Sent Events: {type: "stdout"|"stderr", data: …} chunks, then {type: "exit", code: 0} and {type: "done"}.3
Put files in, run against them, read the result back
POST /sandbox/{sandboxId}/files writes a batch in one call — each entry a path
(relative to /workspace) and base64 content; base64 in and out, always. Let jq encode:etl.py
path defaults to /workspace; recursive=true walks subdirectories):content is base64, size in bytes) — or fetch raw bytes from
GET /files/download?path=… when the file is binary or big:4
Expose a port through a tunnel
A sandbox has no inbound route by default; Callers reach the tunnel
POST /sandbox/{sandboxId}/tunnels opens one to
a server the agent started. Start it in a way that returns (execute is synchronous), then
tunnel: port is required; protocol is http or https; expires_in 60–86400 s.Background a server through execute —
{"command": "nohup python3 -m http.server 8000 >/tmp/http.log 2>&1 & sleep 1"} — so the
call returns, then:url with the token in an x-stateset-tunnel-token header (or
as a Bearer token — the one place Bearer appears here). public is accepted for backward
compatibility, but every tunnel requires its token today. Audit with
GET /sandbox/{sandboxId}/tunnels; close early with DELETE /tunnels/{tunnelId} —
addressed by tunnel id, not sandbox id.5
Run inference without a key in the sandbox
Code inside the sandbox holds no provider API keys, deliberately.
The response wraps the provider’s answer —
POST /sandbox/{sandboxId}/inference proxies an LLM call instead: the controller decrypts
the org’s stored secret, injects the auth header, and forwards body verbatim — the key
never enters the sandbox. provider is anthropic, openai or google; path is the
provider’s own API path; secret_name overrides the default stored secret
(store one with POST /api/v1/secrets)
(ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY); headers adds extras.status, body and headers are the
provider’s. A 4xx inside a 200 envelope means the provider rejected the forwarded
request; read body for its error.6
Extend the lifetime — before it expires
The job overran the 900 s you guessed at creation. Extensions are capped, not refused: a sandbox lives at most
POST /sandbox/{sandboxId}/extend
pushes expires_at out by additional_seconds (minimum 60) — on a running sandbox; a
terminated one cannot be revived, so extend before the clock wins.max_lifetime_seconds (24 h)
from creation, so applied_additional_seconds may come back smaller than requested, and
remaining_extend_seconds is what is left for future extends. Treat applied < requested
as the signal that this sandbox is near end of life.7
Tear it down
Two doors out:
POST /sandbox/{sandboxId}/stop returns {"success": true};
DELETE /sandbox/{sandboxId} does the same and adds a message. Use either — deliberately,
rather than letting the timeout do it.When the agent outlives the sandbox
A single sandbox is capped at 24 hours and a create call at one hour, on purpose — the sandbox is a lease, not a home. A long-running agent should not fight that with an extend loop; it should run inside an agent session: a durable wrapper on the same controller that carries a budget (costCapCents,
iterationLimit, durationLimitSeconds) and rotates the sandbox underneath when the
current one nears its timeout — a fresh pod from the same spec, with the working directory,
environment and session context carried across. The cost cap is the piece this guide’s flow
lacks: every exec is charged against it, and the session stops itself when the budget is
spent, instead of billing until someone notices. Files outside the working directory do not
survive a rotation — anything the next sandbox must know goes in the session context, exactly
like summary.json went to /workspace here.
When it does not go to plan
401 on every call, key is definitely right
401 on every call, key is definitely right
The scheme is
Authorization: ApiKey <key>. Sending Bearer <key> makes the controller
try to parse your API key as a JWT — the 401 never mentions the scheme, so check this first.The file I wrote contains my base64 string, literally
The file I wrote contains my base64 string, literally
files[].content must already be base64; raw text is written as the literal characters of
your string. Same in reverse: GET /files returns base64, so base64 -d before diffing.Execute never returns
Execute never returns
The call is synchronous, and a foreground server never exits. Background it in a shell
(
nohup … &) as in the tunnel step, or set "stream": true and read SSE until the exit
event. The sandbox timeout, not your HTTP client, should be the bound on a long command.Extend applied less than I requested
Extend applied less than I requested
Not an error — the 24 h
max_lifetime_seconds cap from creation time. Compare
applied_additional_seconds with requested_additional_seconds, and plan the handoff to a
new sandbox (or an agent session) once remaining_extend_seconds approaches zero.The tunnel URL answers 401
The tunnel URL answers 401
Every tunnel requires its
token, even when created with "public": true (the flag is
retained for compatibility). Send x-stateset-tunnel-token: <token> — or a Bearer token —
to the tunnel url, and mind expires_at: a tunnel closes on its own TTL.What you built
Next steps
Agent sessions
The rotation-with-a-budget pattern in full — create a session, exec against it, carry context across sandboxes.
Sandbox webhooks
sandbox.ready, command.completed and twenty-two other events, instead of polling /status.Runtime selection
Container, gVisor or MicroVM — what each buys you for the
isolation field.Execute reference
The generated reference for
/execute and, alongside it, every endpoint used here.