Sandbox compute
POST /v1/sandboxes runs code in an isolated sandbox, bills it in vCPU-seconds
and GB-seconds of memory, and returns a signed receipt. Same API key as
inference, same credit balance, same spend caps.
Agents need somewhere to run code far more often than they need a GPU. What Sable adds on top of the execution itself is the control plane: a hard spend ceiling, a key scope, a retry that cannot execute or bill twice, and a per-run receipt you can hand to someone else as evidence.
Run some code
curl https://api.buildsable.com/v1/sandboxes \
-H "authorization: Bearer $SABLE_API_KEY" \
-H 'content-type: application/json' \
-d '{
"language": "python",
"code": "print(sum(range(10)))"
}'The response:
{
"id": "sbx_7f3c…",
"object": "sandbox.run",
"status": "succeeded",
"exit_code": 0,
"stdout": "45\n",
"stderr": "",
"truncated": false,
"duration_ms": 412,
"vcpu": 1,
"vcpu_seconds": 1,
"cost_micro_usd": 17,
"receipt": { "receipt": "eyJ2IjoyLC…", "signature": "0x…", "signer": "0x…" },
"replayed": false
}
Each output stream is capped at 256 KiB, cut on a UTF-8 character
boundary; when either stream is cut, truncated is true.
Request fields
| Field | Default | Notes |
|---|---|---|
code | required | The program to run. Fed to the runtime over stdin. |
language | python | python, node, or bash. |
image | per language | Explicit runtime image. Overrides the language default. |
timeout_secs | 30 | Clamped to the deployment maximum: 4 hours on production. For long jobs use stream: true so output (and liveness) arrives as it happens. |
vcpu | 1 | Clamped to the deployment maximum. |
mem_mb | 512 | Clamped to the deployment maximum. Memory is billed (see below), so ask for what the run needs, not the ceiling. |
env | none | Environment variables. Request-scoped, never stored. |
network | false | Network egress. Off by default: generated code doesn't get the internet unless you ask. On a deployment whose sandbox backend does not permit egress, network: true is refused rather than silently ignored. |
Streaming output
Pass "stream": true and the response becomes an SSE stream: sable.stdout
and sable.stderr events carry output the moment your program prints it,
and a terminal sable.result event carries the exact same response body the
buffered call returns, signed receipt included. Billing is identical either
way, and a client that disconnects mid-stream is still billed for the run
(the slot was held and the run completes on the node).
curl -N https://api.buildsable.com/v1/sandboxes \
-H "authorization: Bearer $SABLE_API_KEY" \
-H 'content-type: application/json' \
-d '{
"stream": true,
"language": "python",
"code": "import time\nfor i in range(3):\n print(i, flush=True)\n time.sleep(1)"
}'
# event: sable.stdout data: {"text":"0\n"} ← arrives at t≈0s
# event: sable.stdout data: {"text":"1\n"} ← t≈1s
# event: sable.stdout data: {"text":"2\n"} ← t≈2s
# event: sable.result data: { …full response with receipt… }Output caps apply identically (256 KiB per stream); chunks past the cap are
dropped from the stream and truncated is true on the result. On a node
that predates streaming, the gateway falls back to a buffered run and the
terminal event still carries the full output.
Both SDKs consume this for you: sable.runCodeStream(req) (TypeScript, an
async iterator of stdout/stderr/result events) and
sable.run_code_stream(...) (Python, a generator yielding ("stdout", text) /
("stderr", text) then ("result", run)).
Retrying safely
A sandbox call executes code and bills for it, so a blind retry after a dropped
connection is a second execution and a second charge. Send an optional
Idempotency-Key header and the retry is safe: at most one execution per
key, per account. The key is claimed before the code runs, so two concurrent
retries race to a single winner rather than both executing.
curl https://api.buildsable.com/v1/sandboxes \
-H "authorization: Bearer $SABLE_API_KEY" \
-H 'content-type: application/json' \
-H 'Idempotency-Key: 6f1c2a8e-4b77-4a1d-9d0e-2b7d5c9a1f30' \
-d '{
"language": "python",
"code": "print(sum(range(10)))"
}'The key is any printable-ASCII string up to 200 characters: a UUID or a
hash of the job is the usual choice. A non-ASCII or over-length key is a 400
rather than a silent no-op, because a key that quietly does nothing is worse
than no key at all. Keys are scoped to your account, and a request without the
header behaves exactly as it did before.
| Situation | What you get |
|---|---|
| First request with a key | The run executes normally. "replayed": false. |
| Retry after that run finished | The original signed receipt, replayed byte-identical, with "replayed": true. The code is not executed again and nothing is billed again. |
| Retry while the run is still in flight | 409 with type: conflict. The only alternatives would be to stall or to run your code twice. Retry once it finishes. |
Retry after the run failed (502 backend or capacity unavailable) | The key was released, so this genuinely re-runs. Nothing was billed for the failed attempt. |
| Key over 200 chars, or containing non-ASCII | 400, before anything runs. |
A replay returns empty output
Sandbox stdout and stderr are never written to disk or a log line (see
Privacy below, and the privacy contract), so there is
nothing to replay them from. A replayed response carries the original receipt
and the run metadata, and both output streams come back as "":
{
"id": "sbx_7f3c…",
"object": "sandbox.run",
"status": "succeeded",
"exit_code": 0,
"stdout": "",
"stderr": "",
"truncated": false,
"duration_ms": 412,
"vcpu": 1,
"vcpu_seconds": 1,
"cost_micro_usd": 17,
"receipt": { "receipt": "eyJ2IjoyLC…", "signature": "0x…", "signer": "0x…" },
"replayed": true
}
Keep the output from the first response. Idempotency protects your credit
and your side effects; it cannot hand you back an answer the gateway
deliberately never kept. Always branch on replayed before treating stdout
as the result of the run.
What it costs
Billing has two dimensions, both wall-clock, both rounded up, minimum one second (a partial second still occupies a slot):
- vCPU-seconds: seconds the run held its slot × vCPUs requested.
- GB-seconds of memory: seconds × GB requested (default rate 2 micro-USD per GB-second ≈ $0.0072 per GB-hour, before margin). Memory is what actually limits how many sandboxes a host can run at once, so it is priced rather than free.
cost_micro_usd on the response is the total charged amount across both
dimensions, and it also appears in the receipt and in GET /v1/usage.
Two things you are not billed for:
- Capacity we failed to deliver. If the backend is unavailable the call
returns
502witherror_class: backend_unavailableand costs nothing. The attempt is still recorded so you can see it happened. - Requests we refuse. Scope denials, spend-cap rejections, and credit exhaustion all short-circuit before any capacity is reserved.
A run that times out is billed: it held the slot for the full window.
While a run is in flight it reserves its worst case against your prepaid
balance (every second of its timeout_secs, on every vCPU and every MB of
memory it asked for), and the reservation is released the moment the run
settles, when you are charged the actual usage instead. A generous timeout_secs
with a large mem_mb therefore reserves a lot of credit even if the run
finishes in a second. Spendable credit is your balance minus what
in-flight requests have reserved, so a 402 can mean credit is reserved by
requests already in flight rather than that your balance is empty; the error
message says which. That state is temporary: retry once those runs settle. See
Cost & metering.
Concurrency
An account can hold a bounded number of sandbox runs in flight at once
(default 8). A request over the cap returns 429 with a Retry-After
header: nothing is executed and nothing is billed. Queue on your side and
retry, or finish runs faster with a tighter timeout_secs.
Spend controls
Everything that governs an inference key governs sandboxes too (see API key controls):
- Spend cap: a key over its monthly cap gets
402before anything runs. - Resource allowlist:
allowed_modelsdoubles as a resource allowlist. A key with an allowlist must includesandbox(or the specific image) or the run is refused with403. A key scoped to one model can't quietly pivot into running arbitrary code. - Expiry and per-key rate limits apply unchanged.
Privacy
Your code, its stdout, and its stderr cross the gateway in-frame, are returned to
you, and are never written to disk or a log line. The sandboxes table has no
column that could hold them.
The one content-derived value that persists is content_fp: a sha256 prefix of
the submitted payload, the same fingerprint scheme the inference
receipts use. It proves which payload ran without revealing
what it was.
Receipts
Sandbox receipts are v: 2 and describe compute rather than tokens:
{
"v": 2,
"kind": "sandbox",
"resource": "python:3.12-slim",
"unit": "vcpu_seconds",
"quantity": 1,
"cost_micro_usd": 17,
"status": "succeeded",
"exit_code": 0,
"content_fingerprint": "3954a524ccabab70",
"logging": "metadata-only"
}
They are signed by the same key as inference receipts and verify through the same
POST /v1/receipts/verify. A distinct version rather than a reinterpreted
inference receipt, because vCPU-seconds are not tokens and a receipt shouldn't lie
about what it measured.
Isolation, honestly
Production sandboxes run on Sable-operated infrastructure. A deployment runs one of two backends:
http-runner: a gVisor/microVM runner behind a small wire contract (published at node agent). This is the production path: every job runs in a gVisor sandbox with capabilities dropped, a read-only rootfs, memory and pids ceilings, and no network unless the request asks for it. The gateway schedules each run onto an enrolled fleet node (today a single Sable-operated house node), claiming a per-job concurrency lease in a database transaction before it dials, and falls back to the configured single backend when no enrolled node has a free slot.local-docker: the local Docker daemon. Correct for development and single-tenant self-hosting, but it is not a security boundary against hostile code: a container escape lands on the gateway host. The gateway logs a warning at boot when this backend is selected.
If a run is refused with 501, sandbox compute isn't enabled on that deployment.