Home / Docs / GPU sandboxes · also: Quickstart · Errors

GPU sandboxes — user guide

Five REST calls take you from nothing to a job on a GPU. POST /sandboxes with {"accel": {"vram_gb": 2}} on https://api.sbx.agent-next.com (header X-API-Key: <your key>) creates the lease, POST /sandboxes/{id}/exec runs Python or a shell command on it, POST /sandboxes/{id}/pause and /resume release and restore the GPU (a lease that cannot be spilled is frozen instead and keeps its seat, see Pause / resume), and DELETE /sandboxes/{id} frees the lease. The GPU hello world quickstart runs all five. The same lease plane is reachable three ways: these REST verbs, the sbx CLI's gpu verbs (sbx gpu create/list/exec/pause/resume/kill/templates/info), and the MCP tools (sandbox_create with a GPU template, sandbox_exec, sandbox_kill) — see Three ways in for one verified example each.

Last verified against the gateway code: 2026-10-16.

A GPU sandbox is the same object as a CPU sandbox with one extra dimension: the create body carries an accel object and the lease plane serves it with the same verbs (create/get/pause/resume/destroy), the same auth (X-API-Key), and the same gl-… id namespace. One host GPU is time-multiplexed between leases: your lease holds a VRAM slice you ask for at create, and while it is paused the GPU is normally released (state is spilled to host RAM and restored bit-exact on resume). A lease that cannot be spilled is frozen instead and keeps its seat; see Pause / resume.

GPU is gated: the plane is operator-flagged per host, and your key needs a GPU grant or a GPU-enabled tier. Without one, treat a GPU create as unavailable (403 on a tier without GPU, 402 with an empty GPU bucket — see Errors).

Base URL and key: the public REST API is https://api.sbx.agent-next.com, auth is the X-API-Key: <your key> header on every request (the gateway reads only that header on this surface — a Bearer token is the E2B SDK's own convention, not one this REST path accepts). A ready-to-run client for the demo below is examples/gpu/run_mjwarp_demo.py.

Quickstart: GPU hello world

Five curl calls, create to destroy. You need a key with a GPU grant (see the gate note above) in SBX_API_KEY.

API=https://api.sbx.agent-next.com
H=(-H "X-API-Key: $SBX_API_KEY" -H "Content-Type: application/json")

# 1. create a 2 GiB lease
curl -s -X POST "${H[@]}" -d '{"accel": {"vram_gb": 2}}' $API/sandboxes
# 201 -> {"sandboxID": "gl-1a2b3c-1", "templateID": "gpu-lease-v06", "phase": "resident",
#         "accel": {"class": "nvidia.5090", "vram_gb": 2.0, "network": "none"}}
ID=gl-1a2b3c-1   # the sandboxID from the reply

# 2. run Python on the GPU (code is Python source; send cmd instead for a shell command, see Exec)
curl -s -X POST "${H[@]}" $API/sandboxes/$ID/exec -d '{"code":
  "import torch; report[\"gpu\"] = torch.cuda.get_device_name(0); print(torch.cuda.is_available())",
  "timeout_s": 60}'
# 200 -> {"sandboxID": "gl-1a2b3c-1",
#         "result": {"ok": true, "report": {"gpu": "NVIDIA GeForce RTX 5090"},
#                    "stdout": "True\n", "stderr": "", ...},
#         "meters": {"billed_s": ..., "running_s": ..., ...}}

# 3. pause (this lease gives the GPU back) and resume (state restored)
curl -s -X POST "${H[@]}" $API/sandboxes/$ID/pause    # 200 -> {"sandboxID": ..., "phase": "suspended"}
curl -s -X POST "${H[@]}" $API/sandboxes/$ID/resume   # 200 -> {"sandboxID": ..., "phase": "resident"}

# 4. destroy: frees the slice, returns the final meter snapshot (billed_s at the top level)
curl -s -X DELETE "${H[@]}" $API/sandboxes/$ID

The device name is whatever card the node serves. A 200 exec reply always has result.ok: true: a job that raises is answered 409 (lease survives), so check the HTTP status, not ok. Python variables persist across execs and pause/resume, so a model loaded in one exec is still there in the next. On a lease from a suspend_mode: "none" template pause still answers 200 but freezes the container instead of spilling it: the lease keeps its seat and bills its whole residency. On a hard-cap host a transparent lease is frozen the same way when processes other than its worker hold VRAM, and then bills the frozen time (see Pause / resume). Always destroy in a finally.

GPU lease lifecycle contract

What ends a lease, what it costs while it lives, and its limits. Every row is sourced from code; the per-verb detail is in the sections below.

ends the leaseresult
DELETE /sandboxes/{id}slice freed, workdir (outputs) removed, final meter snapshot returned as the reply body
exec runs past timeout_sjob fails and the lease is killed (dead); DELETE it, then create again
GPU credit runs out while an exec is runningthe job is stopped and the lease destroyed; the exec answers 402 (checked every 5 s by default)
VRAM over the hard cap (watchdog, hard-cap hosts)SIGKILL within ~0.1 s, dead, key flagged for review
worker process dies (mid-exec, between execs, or during a pause/resume toggle)dead; the reaper kills a phantom seat
create timeout or maximum lease age (GATEWAY_GPU_MAX_LEASE_AGE_S, default 86400 s)killed even if busy, idle or paused; persisted deadlines include gateway downtime

Idle is not an end either: a lease idle past accel.idle_timeout_s (default 600 s for transparent templates, off for suspend_mode: none; 0 turns this timer off, see Create for the plane's other idle parking) is paused by the reaper through the POST /pause path, and the next exec or resume thaws it. See Idle auto-pause.

A dead lease frees its seat and its place under the per-owner cap at once and is purged from listings after the reaper's dead-lease retention (default 3600 s); later verbs on it answer 404/409. It leaves a tier cap (a self-serve verified key holds 1 lease) too: on a node fleet a key at its cap is checked against the nodes holding its leases, a lease its node lists dead or no longer lists stops counting, and a node that cannot be read keeps its leases counted. A dead lease stays registered until you DELETE it, so delete it and create again. Pause is not an end: a paused lease keeps its id and state but still counts toward the per-owner cap until destroyed.

Create

POST /sandboxes
{"accel": {"vram_gb": 4, "template": "mujoco"}}
curl -s -X POST -H "X-API-Key: $SBX_API_KEY" -H "Content-Type: application/json" \
  -d '{"accel": {"vram_gb": 4, "template": "mujoco"}}' \
  https://api.sbx.agent-next.com/sandboxes
# 201 -> {"sandboxID": "gl-1a2b3c-1", "templateID": "mujoco", "phase": "resident",
#         "accel": {"vram_gb": 4.0, ...}}

A lease starts in phase resident. Phases you can observe on GET /sandboxes/{id}: resident (holds the GPU), suspended (paused; the GPU is released unless the lease was frozen and kept its seat, see Pause / resume), dead (killed — see Errors); the meters block on the same reply carries billed_s, running_s, and friends (see Billing).

What fits in a slice

Measured 2026-10-16 on a hard-cap host (RTX 5090, HAMI slice emulation, shared with other tenants; one run per cell — the 290-327 cell is two runs — so treat roughly +-20% as run-to-run spread; slice size did not measurably change render throughput, the cell-to-cell wobble sits inside that spread). The raw logs are held by the operator; the load-bearing numbers are repeated here so the table stands alone.

workload0.5 GiB1 GiB2 GiB4 GiB
Free VRAM in a live worker (mem_get_info, context already up)248 MiB760 MiB1784 MiB3832 MiB
MuJoCo EGL 1080p render (300 frames)380 fps290-327 fps273 fps(not measured)
ManimGX manimgx render 1080p60 (23 s video)23 s wall22 s28 s(not measured)

Exec

POST /sandboxes/{id}/exec
{"code": "<python source>", "timeout_s": 600}

Shell commands (cmd)

POST /sandboxes/{id}/exec
{"cmd": "nvidia-smi -L", "timeout_s": 60, "cwd": "/tmp", "env": {"K": "v"}}

The result object of the reply (sandboxID and meters are as above):

{"ok": true, "seq": 1, "code_wall_s": 0.02, "stdout": "GPU 0: NVIDIA GeForce RTX 5090 (UUID: GPU-...)\n", "stderr": "", "chk": 569521186275328, "report": {"exit_code": 0, "stdout": "GPU 0: NVIDIA GeForce RTX 5090 (UUID: GPU-...)\n", "stderr": ""}, "exit_code": 0}

The MuJoCo Warp demo (examples/gpu/mjwarp_train.py) is one self-contained job source in exactly this shape: sent as code, it trains a Go1 quadruped to walk, renders three clips, and writes model_*.pt, eval.json and clip_*.mp4 into OUTPUT_DIR. Its knobs are MJW_* env vars — but note exec jobs take no env from the request: set them inside the code (import os; os.environ[...]) before the heavy imports, or ask the operator for a template with the env baked in.

Pause / resume

POST /sandboxes/{id}/pause
POST /sandboxes/{id}/resume

Pause is suspend-spill for a transparent lease whose only VRAM user is its worker: your lease's state (tensors, allocator, CUDA context) is spilled to host RAM and the GPU is released completely — other leases take the card while you are paused. Resume restores bit-exact and your slice comes back. Nothing about your Python namespace changes. A lease from a suspend_mode: "none" template, and, on a hard-cap host, a transparent lease whose processes other than the worker hold VRAM, are frozen instead and keep their seat (below).

Files stay readable while paused (see below) — reads need no GPU.

Files (outputs)

GET /sandboxes/{id}/files            -> {"sandboxID": "gl-1a2b3c-1",
                                         "files": [{"name": "clip_policy.mp4", "size": 12345}, ...]}
GET /sandboxes/{id}/files/{name}     -> the bytes (attachment)

In node mode, deploy the artifact routes on the GPU node and the proxy on its front before verifying downloads. The front answers 503 naming the deployment requirement when its node lacks these routes. After rollout, verify an ordinary output download before interpreting rejected-path probes. Reads stream in bounded chunks and retain the existing file-size and ownership gates.

Destroy

DELETE /sandboxes/{id}

Frees the VRAM slice, kills the worker, removes the workdir (outputs are gone — download what you need first), and returns the final meter snapshot as the reply body (billed_s at the top level, no meters wrapper). Always destroy in a finally: a forgotten lease holds its seat until the operator's max-lease-age reaping, and for suspend_mode: "none" leases every second until then is billed. Destroying an unknown or already-destroyed id answers 404.

The per-owner lease cap

One key holds at most 8 live leases per GPU host at a time (operator-set, GATEWAY_GPU_MAX_LEASES_PER_OWNER; each host, and each node of a fleet, counts its own leases for the key). On a single host the ninth create answers 409 naming the cap: owner <your key> holds 8 leases (max 8); destroy one first. On a node fleet the front tries the next node when a node refuses for the cap, so the ninth lease is placed while another node has room for it and holds fewer than 8 of your leases; the cap answer comes back only when no node could take the create. A self-serve verified key is held to 1 by its tier, which the front counts across the whole fleet (see Billing).

The hard VRAM cap

Your vram_gb slice is enforced, not advisory — on hosts running the hard cap (operator flag) there are two layers, and on every host the worker's own allocator is capped to the slice.

Layer 1: allocation fails in-process

The worker boots with torch.cuda.set_per_process_memory_fraction set to your slice, so an over-slice allocation raises a normal torch.cuda.OutOfMemoryError naming what it tried to allocate. Your job sees a regular Python exception: the exec answers 409, the lease survives, and the next exec runs fine. Budget roughly slice − 0.25 GiB − 64 MiB of usable payload (the worker keeps a small checksummed state tensor and CUDA context overhead inside the slice).

The smallest slice is honest but tight. Measured on the 5090 (2026-10-16):

"Usable" means two different numbers, so the table has two columns for it: usable payload is what ONE job process can allocate (the canary is already held — the state a job actually runs in), and budget for a second process is what is left once a second CUDA context (DataLoader worker, render subprocess) has taken its ~512 MiB out of the same slice (no extra context is admitted on top: the cap is the slice).

sliceextra CUDA contexts admittedcanaryhard capusable payloadbudget for a second process
0.5 GiB0 GiB0.25 GiB1332 MiB248 MiB (measured)0 MiB (measured)
1 GiB0 GiB0.25 GiB1844 MiB760 MiB (measured)256 MiB
2 GiB0 GiB0.25 GiB2868 MiB1784 MiB (measured)1280 MiB
4 GiB0 GiB0.25 GiB4916 MiB3832 MiB (measured)3328 MiB
8 GiB0 GiB0.25 GiB9012 MiB7928 MiB7416 MiB
32 GiB0 GiB0.25 GiB33588 MiB32504 MiB31992 MiB

Rows 0.5–4 GiB are measured end-to-end on the 5090 (the usable payload is mem_get_info minus canary-and-context, read inside the worker process); the 8 and 32 GiB rows are the same arithmetic applied to the measured constants (no live lease of that size could be run during the measurement: the GPU was saturated by other work). The 32 GiB (XL) row is arithmetic only for another reason too: nothing above the card's max_admit_gib (28.27 GiB on the 2026-10-17 reading) is admitted, so no lease is seated at 32 GiB on today's card — the row says what the slice would hold, not what you can create today (a slice beyond the card itself is refused at once — the typed gpu_slice_too_large 400 on the single plane, the retryable gpu_capacity_exhausted on a node fleet, where no front can know that no node will ever seat it; between that and the live bound the answer is the retryable gpu_capacity_exhausted).

Your slice is a per-process budget, not a shared one. A second CUDA context in the same lease — a DataLoader worker, a render subprocess — is charged inside your slice. At 0.5 GiB the first job's 248 MiB leaves the child process 0 bytes free (measured: the child allocates nothing and the parent's next allocation fails). Multi-process jobs, start at 4 GiB.

Layer 2: a host watchdog kills runaway leases

With the hard cap on, per-process GPU usage is polled (NVML) and a lease whose processes together exceed its cap — the slice plus a fixed overhead — is SIGKILLed within roughly a tenth of a second of being seen over. The lease goes dead; the exec that was running fails with the kill reason (VRAM cap exceeded: … naming your slice) and the key is flagged for abuse review. There is no partial state to recover — expect to re-run the job.

On hard-cap hosts the gateway also bind-mounts /etc/ld.so.preload, /usr/local/lib/libvgpu.so and /usr/local/vgpu read-only into every worker container, so a root tenant's rewrite lands EROFS and cannot strip the preload for child processes.

Sizing in practice

Size vram_gb above your job's honest peak (context + model + activations + renderer), and treat an OOM as a signal to raise the slice on the next create, not to retry the same one.

Who holds the slice between jobs (accel.reserve)

On a hard-cap host the worker physically holds its slice in torch's caching allocator between jobs. accel.reserve picks what that hold is for:

Every create defaults to "exec" — every template and a create that names no template alike — so a REST create and an MCP sandbox_exec (which forces "exec" because it runs each command as a child process, and a child cannot allocate inside a reserve the worker holds) agree. The MuJoCo Warp, warp and JAX templates have defaulted to "exec" since the policy existed; the 2026-10-14 flip extended that to everything else. Set accel.reserve: "hold" explicitly for the peer-protection hold. Neither value changes what the watchdog kills, what you are billed, or your usable payload.

An operator can set worker_reserve: "cuda" per template in GATEWAY_GPU_TEMPLATES to use the torch-free libcuda worker without changing the gateway-wide default (torch). This is not a public create field and requires GATEWAY_GPU_HARD_CAP=1: configuration fails at boot without it. An unset template field keeps the existing default. A browser template also requires driver_capabilities: ["compute","utility","graphics"], mount_host_conda: false, /opt/browser/bin/python and suspend_mode: "none". Vulkan/WebGL allocations may bypass HAMi CUDA hooks; a functioning host NVML watchdog is mandatory and a measured unbounded overrun blocks live registration. See the browser build guide.

Billing

One number matters: billed_s. The GET /sandboxes/{id} and exec replies carry it in their meters block, and the DELETE reply is the final meter snapshot itself, so billed_s is at its top level; create, pause and resume replies carry no meters. Your key draws from a separate GPU bucket (gpu_credit_hours, drawn by billed GPU-seconds only); when it is empty, GPU create/exec/resume answer 402. Your key's CPU running-hours are a different pool and never gate GPU work: a key that spent its CPU hours can still create, exec and resume a GPU lease while its GPU bucket lasts, and CPU sandboxes keep their own credit gate exactly as before. GET /v0/usage on your key shows gpu_credit_hours / gpu_seconds_used / gpu_seconds_remaining next to the CPU hours.

Self-serve tiers. The verified tier (card-verified) gets 2 GPU-hours per UTC calendar month (TIER_VERIFIED_GPU_HOURS_MONTHLY) and at most 1 GPU lease at a time (TIER_VERIFIED_GPU_LEASES; a paused lease counts, a dead one does not, and the second create answers 409 error_code: "gpu_lease_cap"). The free tier (GitHub sign-in, no card) gets a trial on the same meter: 1 GPU-hour per UTC month (TIER_FREE_GPU_HOURS_MONTHLY), 1 lease at a time (TIER_FREE_GPU_LEASES, same 409), a slice of at most 2 GiB (TIER_FREE_GPU_MAX_VRAM_GB: vram_gb up to 2, or accel.tier S/M), no accel.network: "internet", only the classes in TIER_FREE_GPU_CLASSES (default nvidia.5090, the cards we own; a create that names no class gets the first one), only the default template (accel.template left out or default; the others, such as mujoco, come with the verified tier), and the idle auto-pause stays on (accel.idle_timeout_s of 60 to 600 s; leave it out for the 600 s default). A create beyond that answers 403 error_code: "gpu_free_tier_limit"; the message gives the allowed value and ends with the way out — Contact support to lift this limit, or, where GET /v1/tiers reports card_verification_enabled, Verify a card or contact support to lift this limit — and the body's param names the field (accel.vram_gb, accel.network, accel.class, accel.template or accel.idle_timeout_s). Card verification is not open yet (GET /v1/tiers reports card_verification_enabled), so every self-serve key is on the free tier for now. TIER_FREE_GPU=false switches the trial off: free then answers 403 GPU sandboxes are not included on this tier. On a node fleet a key at its cap (either tier) is checked against the nodes holding its leases: one that a node lists dead or no longer lists stops counting, and one on a node that cannot be read, or whose answer is not a lease list, keeps counting until the node answers properly. The rule, exactly (both tiers):

What accrues to billed_s:

What never accrues: spilled (suspended) time, truly idle residency, the pause spill and the destroy's container stop. Two honest exceptions, both because the lease keeps its card: a suspend_mode: "none" lease bills its whole residency — it holds the card exclusively, so residency is use — and a transparent lease that a pause froze instead of spilling (on a hard-cap host, processes other than its worker hold VRAM, see Pause / resume) bills each frozen interval in full (meters.frozen_billed_s).

meters.billed_s shows these figures on every deployment, and the credit bucket is drawn for them on a single GPU host and on a node fleet alike; the two differ in when the ledger is settled. On a single GPU host the ledger charges a none lease the same way as the meter: residency is settled into the ledger every reap tick and at destroy, and exec time inside it is not billed a second time (before 2026-10-16 only exec time reached the ledger; usage settled before that fix is not re-billed). On a node fleet the front settles each node's counters into its own ledger as busy seconds (exec time, idle intervals that bill as busy, and a freeze-only transparent lease's frozen intervals) and resume seconds. For a none lease it charges the whole residency, its frozen time included, as busy seconds in place of the exec and idle seconds inside it, settled from the node's meters on every reply (a lease read, an exec, the credit watch). A job that is still running counts as running time until it ends (for a node that does not report how long its job has run, by the interval the front itself counts for it; never more than the residency not yet charged, so a job the node reports late is not counted twice). A reply that reaches the front after a newer one was applied charges nothing (two reads in flight can arrive in either order), and a lease that was already open when the front was upgraded is charged the rest of its residency once, on its next reply. meters.idle_billing says "sampled" or "fallback-residency" for the lease you are looking at.

The money rule behind all of it: bill what the card did, measured by the gateway's own clock — never a number the tenant's code produces.

Usage metrics

GET /sandboxes/{id} on a GPU lease carries a gpu block: your live VRAM use against your slice.

"gpu": {"class": "nvidia.5090", "device_name": null, "vram_slice_gib": 2.0,
        "vram_used_mib": 640, "vram_peak_mib": 1200, "util_pct": null,
        "sampled_at": 1700000000.0}

Check capacity

GET /v0/gpu/capacity (same X-API-Key) answers before you create. It is read from the books the plane already keeps; the route itself never runs nvidia-smi (in a node fleet each node reports its classes from its own cached GPU query).

{"classes": [{"class": "nvidia.5090", "nodes": 1, "seats_total": 20,
              "seats_free": 20, "max_admit_gib": 28.27,
              "vram_total_gib": 30.0703125, "vram_free_gib": 29.0703125,
              "templates": ["blender", "browser", "default", "mujoco",
                            "omniverse", "robotsim"]}],
 "nodes_unreachable": 0,
 "you": {"gpu_credit_seconds_remaining": 32400.0, "running_leases": 0,
         "lease_cap": 8}}

Consolidation (node fleet)

A node-mode front places a create on the fullest node that still fits it (best-fit): nodes whose max_admit_gib is below the slice come last, and of the rest the one with the least VRAM left after this slice goes first, so empty nodes stay empty. A node that reports no VRAM figure ranks after the measured ones, most seated first. To retire a node, drain it: POST /internal/gpu/drain {"drain": true} (or boot it with GATEWAY_GPU_DRAIN=1). A draining node reports draining: true, seats_free: 0, max_admit_gib: 0, takes no new lease (the front skips it; a direct create answers 503 gpu_node_draining, Retry-After: 30), and keeps serving its existing leases. The drain is checked again where a create takes its seat, under the lock the drain call takes: a create that was still probing VRAM when the drain landed is refused and launches nothing, while one that already holds its seat keeps it. Its capacity probe carries idle_since, the wall time the node was first seen holding no live lease, resident or paused (null while any lease exists, since releasing the node would lose a paused lease's spilled state, and while a create is in flight, since it is about to take a seat); an autoscaler releases the node once idle_since is set. The front reads a node's 503 gpu_node_draining as "draining" and tries the next node, one that began draining between the front's probe and its create included. When no active node takes a create and a draining node had room for it (or refused it), the front answers the same retryable 503 gpu_node_draining (Retry-After: 30), never the fleet-full 409: a full fleet with a node being retired is scale-in, not a reason to stop retrying (when an active node also failed to boot its worker, that node's 503 gpu_worker_boot_failed is the answer instead, still not the 409). Only when no node would fit, draining or not, is the answer the 409 gpu_capacity_exhausted. Placement also needs a known card: a node whose probe lists no accel class (its GPU query failed, or an older build) takes no lease until it lists one, with or without accel.class, and the front logs the skip. When no reachable node lists a class, the create answers 503 gpu_node_unreachable with Retry-After: 30; so does an internet create that the nodes with a known class decline while such a node advertises egress, instead of the 400 gpu egress disabled. A slice larger than a node's card is that node's own gpu_slice_too_large 400, so placement falls through to the next node; when every node that answered says its card cannot seat the slice, the tenant gets the same retryable 409 gpu_capacity_exhausted a full fleet answers — never the 400, because the front cannot know that no node will ever seat it (a node may be draining, unreachable, mid-probe or rented on demand) — and the message names the largest slice an answering node seats.

On-demand classes (node fleet)

An operator can let a node-mode front rent a card class when a tenant asks for it, instead of keeping a node of every class running. A separate provisioner boots the node; it and the front talk through files. Nothing changes until one of these is set:

settingwhat it is
GATEWAY_GPU_ON_DEMANDthe classes that may be rented: a JSON object {class: {"provider", "sku", "boot_eta_s"}}, or the path of a file holding one. Read once at start. Class names follow the accel.class pattern; boot_eta_s is optional (positive seconds, default 420). One invalid entry is logged and the whole catalog is treated as empty
GATEWAY_GPU_NODES_FILEthe nodes the provisioner rented: {"nodes": [{"name", "url", "token", "classes", "state", "provider", "instance_id", "launched_at"}]}, state being booting, ready or draining. Re-read whenever the file changes, with no restart, and merged with GATEWAY_GPU_NODES (a static node of the same name wins). token is that node's own x-node-token; an entry without one uses GATEWAY_GPU_NODE_TOKEN, which stays required. Only ready nodes take new leases, draining nodes keep serving the leases they hold, booting nodes get no traffic. The file applies whole or not at all: a malformed file, or one entry with a bad name, url (http(s)://host[:port], port 1-65535), state, classes or token (visible ASCII, no spaces), or a name listed twice, is rejected with an error in the log that names the entry, and the last good view stays
GATEWAY_GPU_SUPPLY_FILEthe provisioner's stock report: {"classes": {class: {"state", "eta_s", "since", "node"}}, "updated": <epoch seconds>}, state being ready, booting, no_supply or idle. An updated older than 120 s means the provisioner is down: its no_supply reports are ignored. A missing or malformed file reads as empty
GATEWAY_GPU_DEMAND_FILEwhere the front appends one JSON line {"ts", "class", "key_id"} for a refused create of a rentable class: at most one per key and class every 30 s per front process, and a failed write never fails the request

The provisioner replaces the nodes and supply files by an atomic rename, so a container front must mount the directory that holds them, never either file.

When no ready node lists the class a create asked for, the front answers the first of these that applies:

  1. every node that lists it is draining: 503 gpu_node_draining, as above.
  2. a node that did not answer is known to serve it (it listed the class when last seen, or the nodes file declares it): 503 gpu_node_unreachable. Nothing is rented for a node that is only not answering.
  3. a node of the nodes file is booting for it: 503 gpu_class_provisioning.
  4. the supply file reported no_supply for it at most 600 s ago, and its heartbeat is fresh: 503 gpu_class_no_supply, Retry-After: 300, alternatives the classes served now.
  5. the class is in the catalog: 503 gpu_class_provisioning, and a demand row goes to the provisioner. Retry-After and the body's eta_s are the supply file's eta_s for the class (the time left while it boots one, the measured median while it is idle), else the catalog's boot_eta_s (no supply file, a stale heartbeat, no row for the class or no number in it), and never below 1.
  6. otherwise 400 gpu_class_unavailable, as without the feature.

Errors

All errors are the E2B envelope {"code": <status>, "message": "…"}; 429 and 503 carry an integer Retry-After header, which is the only form the e2b SDK auto-retries — honor it and retry the same request.

statusmeaningwhat to do
400malformed body: accel not an object; vram_gb out of [0.5, 32] or non-numeric; unknown accel.template, accel.network or accel.class (message lists the valid ones; for the class, error_code: "gpu_class_unavailable"); accel.network: "internet" refused by the local route or every reachable GPU node (see the path-specific messages under Create); exec code missing/empty; timeout_s out of (0, 3600]fix the request; the message names the field and the constraint
402GPU credit exhausted (free GPU-hours used up)stop; the bucket is refilled by the operator
403key's tier does not include GPU, a free-tier request beyond the trial (gpu_free_tier_limit), or tenant suspended for abusethe trial limit: lower the request to what the message allows, or contact support to lift it (the message names card verification only while GET /v1/tiers reports card_verification_enabled); the others are not retriable on this key
404unknown id, another tenant's id (indistinguishable), a CPU-plane id where the verb is lease-only, dead lease on file reads, exec/files when the plane is offcheck the id; do not retry blindly
409lease-state conflicts: your job raised (lease survives, message has the traceback tail), timeout_s overrun (lease killed), exec already in flight, pause of a busy lease, pause/exec on a dead lease, your key holding its 8-lease cap (owner … holds 8 leases (max 8); per host or node, so a fleet answers it only when no node could take the create), every GPU node full (no GPU node has a free seat), create collision on a full single-seat planeread the message: it distinguishes them. Job-raised → fix code, lease still usable; overrun → the lease is dead: DELETE it, then create again; cap → DELETE a lease (a paused one counts) and create again; fleet-full → honor the retry hint
429fair-share: your key held the GPU past its streak while another tenant waits (retry in Ns in the message) — or your per-key running quota is full (error_code: "quota_running_exceeded")honor Retry-After, retry the same request; for the quota one, destroy a sandbox first
503plane at capacity (no seat free and nothing idle to evict; message says GPU plane at capacity), host memory below the node's admission floor GATEWAY_GPU_ADMIT_FLOOR_GB (message says GPU node is low on host memory), including an exec or resume of a parked lease the node cannot swap back in because it has no host memory to park an idle lease — or the beta's global GPU budget is exhausted (error: "gpu_global_budget", retry_after_s 300) — or public execution is not enabled on this deploymenthonor Retry-After and retry; capacity frees as other leases spill on pause or are destroyed. The budget is a fleet-wide cumulative cap, so waiting does not clear it — it lifts when an operator raises it, so retry on the interval instead of in a tight loop
413output file over the download capfetch a smaller artifact

Refusals you can act on programmatically also carry a stable error field (equal to error_code); message keeps the human text and nothing else about the envelope changes. The exception is a 409 lease-state conflict (gpu_job_timeout, and gpu_lease_dead when it answers 409): it carries error_code only, never error and never Retry-After, because retrying on the same lease cannot help:

errorstatusextra fieldswhat to do
gpu_capacity_exhausted503 (local plane, or a node refusing an exec or resume — including a parked lease's swap-in when the node has no host memory to park an idle lease for it), 409 (node fleet full)retry_after_s, class, and a Retry-After headerwait retry_after_s and retry; check GET /v0/gpu/capacity
gpu_slice_too_large400param: accel.vram_gbthe slice is larger than the card can seat even empty (its total VRAM less the plane's fixed reserve), so nothing is parked or created and no retry clears it; the message names the largest slice that does seat — lower accel.vram_gb or accel.tier. Single plane only: a node fleet never answers this 400, because no front can know that no node will ever seat the slice — a slice every node it was offered to refused keeps the retryable 409 gpu_capacity_exhausted, its message naming the largest slice an answering node seats
gpu_global_budget503 (create, once the beta's fleet-wide GPU-hour budget is spent)retry_after_s (300), class (null), and a Retry-After headerevery key's billed GPU-seconds together have passed the cap, so no create is admitted. It is cumulative, not a window: waiting does not clear it, and only an operator raising GATEWAY_GPU_GLOBAL_HOURS_CAP does. Retry on the interval and keep an existing lease's exec/resume working
gpu_lease_cap409noneDELETE a lease (a paused one counts, and on a node fleet a dead lease on a node that cannot be read keeps counting) and retry
gpu_credit_exhausted402nonestop; the bucket is refilled by the operator
gpu_free_tier_limit403param: accel.vram_gb, accel.network, accel.class, accel.template or accel.idle_timeout_sa free key asked for more than the trial (by default a slice over 2 GiB, internet egress, a class outside nvidia.5090, a template other than the default, an idle_timeout_s of 0 or over 600 s); lower the request, or contact support to lift it — the message ends Verify a card or contact support to lift this limit only while GET /v1/tiers reports card_verification_enabled, otherwise Contact support to lift this limit
gpu_fair_share429retry_after_s, class, and a Retry-After headeryour key held the GPU past its streak while another waits; wait and retry
gpu_job_timeout409nonethe job outlived timeout_s and the lease was killed: DELETE it, create a new lease and run the job again with a larger timeout_s
gpu_lease_dead409 (lease died), 503 (exec reply lost; lease destroyed)nonethe lease is gone: DELETE it (a 404 means it is already destroyed), create a new one and re-run the job
gpu_class_unavailable400noneaccel.class is not a class the fleet serves now and nothing was placed; pick one from the message or GET /v0/gpu/capacity, or omit accel.class
gpu_class_provisioning503retry_after_s, eta_s, class, and a Retry-After headeraccel.class is rented on demand and no node serves it yet; one was requested and takes about eta_s seconds. Retry after Retry-After, or create with accel.wait_s to wait for it
gpu_class_no_supply503retry_after_s (300), alternatives, class, and a Retry-After headeraccel.class is rented on demand, but the provider reported no stock of it within the last 10 minutes, so no node is on its way. Create with one of alternatives (the classes served now), or retry after 300 s
gpu_node_draining503retry_after_s, class, and a Retry-After headerthis node is being drained, or no active node took the create and a draining one had room for it; wait retry_after_s (30) and retry (a fleet front tries the other nodes first)
gpu_node_unreachable503retry_after_s, class, and a Retry-After headerthe GPU node could not be reached (or answered 503 without a code), the create named an accel.class that no answering node lists while another node did not answer or lists no class (it may serve that class), or no answering node lists any accel class yet (class discovery failed; retry_after_s 30); wait and retry
gpu_worker_boot_failed503retry_after_s, class, and a Retry-After headerthe worker hit a transient CUDA init race on all 3 boot tries (the plane retries them itself); wait retry_after_s and retry
gpu_node_busy503retry_after_s and a Retry-After header; the GET /v0/gpu/capacity answer also carries classthe node was starting or stopping a container and could not answer a capacity, lease-list or drain call within 5 s (or 128 such calls were already waiting); wait retry_after_s and retry

In the retryable bodies class is the class the refusal applies to; it is null when none is known (a node-fleet create that named no class, a lease the front re-adopted after a restart).

Create and resume (local plane and node fleet) both return the capacity body. A create with accel.wait_s > 0 returns it only after the wait expires.

On a node fleet, an exec that the node refuses (fair share, no seat to resume into, a draining node, a worker that failed to boot) answers the same typed body as a single GPU host: error and error_code, message, an integer retry_after_s, class and the Retry-After header. A capacity refusal keeps gpu_capacity_exhausted. A node on an older build refuses without a code; the front then labels a 429 gpu_fair_share and a 503 gpu_node_unreachable.

A 5xx other than these envelopes is a platform fault — not yours, and not made better by immediate tight retries.

Honest limits

Three ways in

The same verbs run over three interfaces. One verified example each, all run against production on 2026-10-18 — create a 0.5 GiB lease, ask for the GPU, destroy it.

REST — this page; the Quickstart above is the full five-call version.

sbx CLI (install: pip install "git+https://github.com/agent-next/sandbox@v0.0.3#subdirectory=cli"):

ID=$(sbx gpu create --vram 0.5)
sbx gpu exec "$ID" -c 'import torch; print("cuda", torch.cuda.is_available(), torch.cuda.get_device_name(0))'
# cuda True NVIDIA GeForce RTX 5090
sbx gpu kill "$ID"

MCP tools — sandbox_create with a GPU template; no pause/resume (REST /pause and /resume do that, see Pause / resume):

MCP=https://api.sbx.agent-next.com/mcp/v1
ID=$(curl -s "$MCP" -H "X-API-Key: $SBX_API_KEY" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"sandbox_create","arguments":{"template":"gpu","vram_gb":0.5}}}' \
  | jq -r .result.structuredContent.sandbox_id)
curl -s "$MCP" -H "X-API-Key: $SBX_API_KEY" -H "Content-Type: application/json" \
  -d "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"sandbox_exec\",\"arguments\":{\"sandbox_id\":\"$ID\",\"command\":\"nvidia-smi -L\"}}}" \
  | jq -r .result.structuredContent.stdout
# GPU 0: NVIDIA GeForce RTX 5090 (UUID: GPU-...)
curl -s "$MCP" -H "X-API-Key: $SBX_API_KEY" -H "Content-Type: application/json" \
  -d "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"sandbox_kill\",\"arguments\":{\"sandbox_id\":\"$ID\"}}}" \
  | jq -r .result.structuredContent.ok
# true

The demo, end to end

examples/gpu/run_mjwarp_demo.py is a stdlib-only client (urllib) that does the whole loop from this page:

export SBX_API_URL=https://api.sbx.agent-next.com
export SBX_API_KEY=<your key>
python3 examples/gpu/run_mjwarp_demo.py

It creates a mujoco-template lease sized for the demo, sends examples/gpu/mjwarp_train.py as the exec code, waits for the job (the demo trains a Go1 to walk — minutes, not seconds), lists and downloads the produced clips via the files API, and destroys the lease in a finally even when something fails. Read it as executable documentation of every verb on this page.

Browser WebGPU smoke (conditional template)

First confirm browser appears in authenticated GET /gpu/templates. If it does not, do not create one. When registered, use REST (not the MCP sandbox_create named-template path) to create:

{"accel":{"template":"browser","vram_gb":2,"reserve":"exec"}}

Send the complete source of examples/gpu/browser_webgpu_smoke.py as code to POST /sandboxes/{id}/exec, with a sufficient timeout_s. The image already holds the offline oracle; the source uses runpy and passes the lease's OUTPUT_DIR and report. Read browser_webgpu_summary.json using the files endpoint and destroy the lease in a finally. Do not enable the separate VRAM probe on a routine 2 GiB lease.

Next: pause semantics · quickstart · GPU templates · limits · errors