> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stateset.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Give your agent an isolated runtime

> Run a data job in a StateSet sandbox end to end — create the pod, execute code, move files in and out, expose a port through a tunnel, call an LLM without keys in the sandbox, extend the lifetime, and tear it down.

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.

|          |                                                                                                 |
| -------- | ----------------------------------------------------------------------------------------------- |
| Base URL | `https://api.sandbox.stateset.app/api/v1`                                                       |
| Auth     | `Authorization: ApiKey <key>` on every call — **not** `Bearer`                                  |
| Lifetime | `timeout_seconds` 30–3600 at creation; extendable to 24 h total (`max_lifetime_seconds: 86400`) |

```bash theme={null}
export SANDBOX_URL="https://api.sandbox.stateset.app/api/v1"
export STATESET_SANDBOX_API_KEY="<key>"
```

<Warning>
  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.
</Warning>

## 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.

<Steps>
  <Step title="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.

    ```bash theme={null}
    curl --request POST "$SANDBOX_URL/sandbox/create" \
      --header "Authorization: ApiKey $STATESET_SANDBOX_API_KEY" \
      --header "Content-Type: application/json" \
      --data '{
        "org_id": "org_demo_7f3a", "session_id": "orders-etl-2026-08-31",
        "cpus": "2", "memory": "2Gi", "isolation": "gvisor",
        "timeout_seconds": 900, "env": { "PIPELINE_STAGE": "dev" }
      }'
    ```

    ```json theme={null}
    { "sandbox_id": "8b1f6f4e-2a3c-4d5e-9f10-6c7d8e9f0a1b",
      "session_id": "orders-etl-2026-08-31", "status": "creating",
      "expires_at": "2026-08-31T16:29:12Z" }
    ```

    Export that id as `SANDBOX_ID`, then poll the lightweight status route until it reads `running` — or subscribe to the
    `sandbox.ready` [webhook](/stateset-sandbox/stateset-sandbox-webhooks) and skip the loop:

    ```bash theme={null}
    curl "$SANDBOX_URL/sandbox/$SANDBOX_ID/status" \
      --header "Authorization: ApiKey $STATESET_SANDBOX_API_KEY"
    # {"sandbox_id": "8b1f6f4e-…", "status": "running", "expires_at": "2026-08-31T16:29:12Z"}
    ```

    <Note>
      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.
    </Note>
  </Step>

  <Step title="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.

    ```bash theme={null}
    curl --request POST "$SANDBOX_URL/sandbox/$SANDBOX_ID/execute" \
      --header "Authorization: ApiKey $STATESET_SANDBOX_API_KEY" \
      --header "Content-Type: application/json" \
      --data '{"command": ["python3", "--version"], "working_dir": "/workspace"}'
    ```

    ```json theme={null}
    { "exit_code": 0, "stdout": "Python 3.12.3\n", "stderr": "" }
    ```

    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"}`.
  </Step>

  <Step title="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:

    ```python etl.py theme={null}
    import csv, json
    with open("/workspace/orders.csv") as f:
        rows = list(csv.DictReader(f))
    total = sum(float(r["amount"]) for r in rows)
    with open("/workspace/summary.json", "w") as f:
        json.dump({"orders": len(rows), "revenue": total}, f)
    print(f"{len(rows)} orders, {total:.2f} revenue")
    ```

    ```bash theme={null}
    jq -n --arg etl "$(base64 -w0 etl.py)" --arg csv "$(base64 -w0 orders.csv)" \
      '{files: [{path: "etl.py", content: $etl}, {path: "orders.csv", content: $csv}]}' |
    curl --request POST "$SANDBOX_URL/sandbox/$SANDBOX_ID/files" \
      --header "Authorization: ApiKey $STATESET_SANDBOX_API_KEY" \
      --header "Content-Type: application/json" \
      --data @-
    # {"success": true, "files_written": 2}
    ```

    Run it, then list the workspace (`path` defaults to `/workspace`; `recursive=true` walks subdirectories):

    ```bash theme={null}
    curl --request POST "$SANDBOX_URL/sandbox/$SANDBOX_ID/execute" \
      --header "Authorization: ApiKey $STATESET_SANDBOX_API_KEY" \
      --header "Content-Type: application/json" \
      --data '{"command": "python3 /workspace/etl.py"}'
    # {"exit_code": 0, "stdout": "412 orders, 18604.50 revenue\n", "stderr": ""}

    curl "$SANDBOX_URL/sandbox/$SANDBOX_ID/files/list?path=/workspace&recursive=false" \
      --header "Authorization: ApiKey $STATESET_SANDBOX_API_KEY"
    ```

    ```json theme={null}
    { "files": [
      { "name": "etl.py",       "path": "/workspace/etl.py",       "type": "file", "size": 291,  "modified_at": "2026-08-31T15:16:41Z" },
      { "name": "orders.csv",   "path": "/workspace/orders.csv",   "type": "file", "size": 9182, "modified_at": "2026-08-31T15:16:41Z" },
      { "name": "summary.json", "path": "/workspace/summary.json", "type": "file", "size": 38,   "modified_at": "2026-08-31T15:17:02Z" } ] }
    ```

    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:

    ```bash theme={null}
    curl "$SANDBOX_URL/sandbox/$SANDBOX_ID/files?path=/workspace/summary.json" \
      --header "Authorization: ApiKey $STATESET_SANDBOX_API_KEY" | jq -r '.content' | base64 -d
    # {"orders": 412, "revenue": 18604.5}
    ```
  </Step>

  <Step title="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:

    ```bash theme={null}
    curl --request POST "$SANDBOX_URL/sandbox/$SANDBOX_ID/tunnels" \
      --header "Authorization: ApiKey $STATESET_SANDBOX_API_KEY" \
      --header "Content-Type: application/json" \
      --data '{"port": 8000, "protocol": "http", "expires_in": 1800, "public": false}'
    ```

    ```json theme={null}
    { "tunnel_id": "0f6b2c9e4a1d4e8f9b3a7c5d1e0f6a2b",
      "sandbox_id": "8b1f6f4e-2a3c-4d5e-9f10-6c7d8e9f0a1b",
      "port": 8000, "protocol": "http",
      "url": "https://api.sandbox.stateset.app/api/v1/tunnel/0f6b2c9e4a1d4e8f9b3a7c5d1e0f6a2b",
      "expires_at": "2026-08-31T15:49:12Z",
      "token": "9c1e…64 hex chars…f2a7" }
    ```

    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.
  </Step>

  <Step title="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`](/api-reference/sandbox/secrets-create))
    (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY`); `headers` adds extras.

    ```bash theme={null}
    curl --request POST "$SANDBOX_URL/sandbox/$SANDBOX_ID/inference" \
      --header "Authorization: ApiKey $STATESET_SANDBOX_API_KEY" \
      --header "Content-Type: application/json" \
      --data '{
        "provider": "anthropic", "path": "/v1/messages", "secret_name": "ANTHROPIC_API_KEY",
        "body": { "model": "claude-sonnet-4-5", "max_tokens": 512,
          "messages": [{ "role": "user", "content": "Summarize: {\"orders\": 412, \"revenue\": 18604.5}" }] }
      }'
    ```

    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.
  </Step>

  <Step title="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.

    ```bash theme={null}
    curl --request POST "$SANDBOX_URL/sandbox/$SANDBOX_ID/extend" \
      --header "Authorization: ApiKey $STATESET_SANDBOX_API_KEY" \
      --header "Content-Type: application/json" \
      --data '{"additional_seconds": 1800}'
    ```

    ```json theme={null}
    { "sandbox_id": "8b1f6f4e-2a3c-4d5e-9f10-6c7d8e9f0a1b", "status": "running",
      "expires_at": "2026-08-31T16:59:12Z", "max_expires_at": "2026-09-01T16:14:12Z",
      "max_lifetime_seconds": 86400, "remaining_extend_seconds": 83700,
      "requested_additional_seconds": 1800, "applied_additional_seconds": 1800 }
    ```

    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.
  </Step>

  <Step title="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.

    ```bash theme={null}
    curl --request DELETE "$SANDBOX_URL/sandbox/$SANDBOX_ID" \
      --header "Authorization: ApiKey $STATESET_SANDBOX_API_KEY"
    # {"success": true, "message": "Sandbox 8b1f6f4e-… deleted"}
    ```

    <Warning>
      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.
    </Warning>
  </Step>
</Steps>

## 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](/stateset-sandbox/stateset-sandbox-agent-sessions): 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

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

## What you built

| Piece             | Endpoint                                                             | Purpose                                                        |
| ----------------- | -------------------------------------------------------------------- | -------------------------------------------------------------- |
| Isolated runtime  | `POST /sandbox/create` (+ `/create-from-template`)                   | A gVisor pod with CPUs, memory, env and a clock                |
| Readiness check   | `GET /sandbox/{id}/status`, `GET /sandbox/{id}`                      | `creating` → `running` before the first command                |
| Execution         | `POST /sandbox/{id}/execute`                                         | Sync `exit_code`/`stdout`/`stderr`, or SSE with `stream: true` |
| Workspace I/O     | `POST` · `GET /sandbox/{id}/files`, `/files/list`, `/files/download` | Base64 in and out; listing with sizes and timestamps           |
| Inbound route     | `POST` · `GET /sandbox/{id}/tunnels`, `DELETE /tunnels/{id}`         | Token-guarded URL to a port, on its own TTL                    |
| Keyless inference | `POST /sandbox/{id}/inference`                                       | Provider calls proxied; the API key never enters the sandbox   |
| More time         | `POST /sandbox/{id}/extend`                                          | Push `expires_at` out, capped at 24 h from creation            |
| Teardown          | `POST /sandbox/{id}/stop`, `DELETE /sandbox/{id}`, `GET /sandboxes`  | Explicit stop, plus the leak sweep                             |

## Next steps

<CardGroup cols={2}>
  <Card title="Agent sessions" icon="rotate" href="/stateset-sandbox/stateset-sandbox-agent-sessions">
    The rotation-with-a-budget pattern in full — create a session, exec against it, carry context across sandboxes.
  </Card>

  <Card title="Sandbox webhooks" icon="webhook" href="/stateset-sandbox/stateset-sandbox-webhooks">
    `sandbox.ready`, `command.completed` and twenty-two other events, instead of polling `/status`.
  </Card>

  <Card title="Runtime selection" icon="layer-group" href="/stateset-sandbox/stateset-sandbox-runtime-selection">
    Container, gVisor or MicroVM — what each buys you for the `isolation` field.
  </Card>

  <Card title="Execute reference" icon="code" href="/api-reference/sandbox/sandbox-by-execute-create">
    The generated reference for `/execute` and, alongside it, every endpoint used here.
  </Card>
</CardGroup>
