Skip to main content
An agent that writes code needs somewhere to run it that is not your infrastructure. This guide takes one job — summarize an orders CSV with a script the agent wrote — and runs it in a StateSet sandbox: an isolated pod with its own /workspace, a resource ceiling, and a clock.
The auth scheme is ApiKey, not Bearer. The rest of the StateSet platform uses Bearer, which makes this the single most common integration mistake with this API.

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.
Export that id as 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.
The call is synchronous — a long build holds the connection open. To watch live, set "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
Run it, then list the workspace (path defaults to /workspace; recursive=true walks subdirectories):
Read the result back (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; 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:
Callers reach the tunnel 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. 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.
The response wraps the provider’s answer — 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. 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.
Extensions are capped, not refused: a sandbox lives at most 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.
A sandbox runs, and bills, until it is stopped or expires_at passes — and an extend moves that moment. Put the stop in a finally block, and sweep for leaks: GET /sandboxes?org_id=org_demo_7f3a lists every sandbox with status and expires_at; anything running that your orchestrator no longer knows about is a leak to stop now.

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

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.
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.
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.
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.
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.
Last modified on August 31, 2026