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 lease | result |
|---|---|
DELETE /sandboxes/{id} | slice freed, workdir (outputs) removed, final meter snapshot returned as the reply body |
exec runs past timeout_s | job fails and the lease is killed (dead); DELETE it, then create again |
| GPU credit runs out while an exec is running | the 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.
- Billing. Only exec time, resume time and sampled-busy idle accrue to
billed_s; spilled (suspended) time and truly idle residency bill 0. Frozen time bills in full: asuspend_mode: "none"lease bills its whole residency, and a transparent lease that a pause froze bills the frozen intervals. Details: Billing. - Limits.
accel.vram_gbin [0.5, 32] GiB; exectimeout_sin (0, 3600] (default 120); one job at a time per lease (a second exec answers 409); at most 8 live leases per key on each GPU host or node (GATEWAY_GPU_MAX_LEASES_PER_OWNER; a self-serveverifiedkey holds 1 in total, see Billing);reportunder 4 MiB;stdout/stderrkeep the last 64 KiB; no network inside the container unless you create the lease withaccel.network: "internet"(see Create). The free tier's GPU trial is tighter (1 lease, slice up to 2 GiB, classnvidia.5090, default template only, nointernetegress,idle_timeout_s60 to 600 s): Billing. - Errors. Status-by-status table: Errors.
- Operators: the reaper removes this gateway's worker containers that no live lease owns once they are older than 120 s, every
UNOWNED_SWEEP_S= 30 s (the boot sweep alone missed a container the previous process was still launching when it was killed). - Pause semantics for leases that run child processes (freeze versus spill, what stays billed): the pause page.
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, ...}}
timeout(top level) is the GPU lease lifetime in seconds, a positive finite number at most the operator'sGATEWAY_GPU_MAX_LEASE_AGE_S(default 86400). Values above that limit, booleans and malformed values answer 400gpu_invalid_timeoutbefore a worker is created. Omit it to use the operator maximum, preserving the existing GPU default. The list'sendAtreflects this deadline. Expiry kills the lease even while a job is busy or the lease is paused; it is separate from exectimeout_sand idle auto-pause. The persisted deadline includes gateway downtime. Older saved records without a wall deadline retain the previous max-age behavior.accel.vram_gbis your VRAM slice in GiB, a number in [0.5, 32]. Out-of-range or non-numeric values answer 400 naming the field and the range. Omit it for the default (1 GiB). What actually fits in a slice is measured, not guessed — see What fits in a slice.accel.tieris a named slice:S1,M2,L8,XL32 GiB (case does not matter). It is an alternative tovram_gb: send one, or both when they agree; a tier that does not matchvram_gband an unknown tier answer 400. The reply echoesaccel.tier. Combine it withaccel.classto pick the card:{"accel": {"class": "nvidia.5090", "tier": "M"}}. A slice larger than the card itself — its total VRAM less the plane's fixed reserve, soXLon today's card — answersgpu_slice_too_large(400) at once, naming the largest slice that does seat: no idle lease is parked to make room (evicting could never seat it) and no retry clears it. Momentary VRAM pressure never produces that 400 — a slice the empty card seats keeps the retryablegpu_capacity_exhausted. That 400 is the single plane's answer; on a node fleet the front cannot know that no node will ever seat the slice (a node may be draining, unreachable, mid-probe or rented on demand), so a slice every node it was offered to refused keeps the retryable capacity refusal, its message naming the largest slice an answering node seats.accel.templatenames an operator-allowlisted worker image (e.g.mujoco— MuJoCo Warp robot training with EGL rendering). The name is the only thing a request may pick: images, interpreters, env and suspend mode come from the operator's config and never from a request. An unknown name answers 400 listing the valid ones; omit it for the plaindefaultCUDA worker.GET /gpu/templatesand the per-classtemplatesfield ofGET /v0/gpu/capacitylist the GPU catalog — the list is operator-defined and differs per deployment. On a node-mode front,GET /templateslists CPU templates only; it adds GPU rows only when a local GPU manager is enabled. Whereblendernames both a CPU and a GPU template, a create withaccel.template: "blender"selects the GPU template, while a create withoutaccelusingtemplateID: "blender"selects the CPU template. Inside themujocoimage, check the stack by importing it (import mujoco, mujoco_warp, jaxandfrom mujoco import mjx): those packages carry no pip metadata there, sopip show mujocofails while the import works. TheblenderGPU template has nobpymodule in the exec interpreter; run Blender headless instead, e.g./opt/blender/blender -b --python script.pyfrom asubprocesscall. Abrowsertemplate is available only if an operator has built, tested and registered it; it is not live merely because its Dockerfile exists.accel.class(optional) picks the card: a class the fleet serves now (e.g.nvidia.5090;GET /v0/gpu/capacitylists them). The lease is placed only on a node that lists the class, and the create reply,GET /sandboxes/{id}and the list report the class it landed on. A class nothing serves answers 400gpu_class_unavailable, names the available ones and places nothing; while a node did not answer, the same request is a retryable 503gpu_node_unreachableinstead, because that node may be the one serving the class. A node whose capacity probe lists no class (its GPU query failed) is never read as some default card: it takes no lease, with or withoutaccel.class, until it lists one. While such a node exists, a class no other node serves is the same retryable 503 (Retry-After: 30) rather than a 400, since that node may be the one serving it. A fleet whose operator rents some classes on demand answers a class it does not serve yet with a retryable 503 rather than that 400:gpu_class_provisioning(a node for it was requested;Retry-Afterandeta_ssay when to retry) or, when the provider had no stock lately,gpu_class_no_supply(Retry-After: 300;alternativeslists the classes served now). Omit it to take whichever node has a seat: the reply then names the class you got. The error text is the discovery mechanism when in doubt. On the free tier only the trial's classes are allowed (nvidia.5090by default) and an omitted class is the first of them: Billing.accel.reserve(optional, hard-cap hosts only) picks what the worker does with its slice reserve between jobs:"exec"(default) or"hold". Any other value answers 400 naming the field. The trade-off — and why"exec"is the default — is spelled out under The hard VRAM cap.accel.network(optional) is"none"(default —--network none, no connectivity) or"internet"— public egress only (pip, Hugging Face, git clone): the docker host, private ranges and other sandboxes stay unreachable. It works only where the operator opted the node in; refusals depend on the create path (the text below uses placeholders): the local route answers 400gpu egress disabled on this node; if the lease manager refuses instead, it answers 409lease create failed: gpu egress disabled on this node. The node front answers 400lease create failed: gpu egress disabled on every reachable GPU node (<refusals>; <answered>/<configured> answered the probe), where each refusal isnode <name> does not advertise gpu egressornode <name> refused: <node message>, joined with;. These disabled-egress replies do not seterror_code; check the HTTP status and treat the text as diagnostic detail. If a node is unreachable or its egress firewall is pending, the front can answer 503 instead. The reply echoes it back asaccel.network, as doGET /sandboxes/{id}and the list.accel.idle_timeout_s(optional) sets when an idle lease pauses itself:0turns this per-lease timer off, otherwise a number of seconds in [60, 86400]; any other value answers 400 naming the field. Omitted, the default is 600 s for a transparent template and off for asuspend_mode: "none"template. The timer is not the plane's only idle parking: it also parks a transparent lease that has sat idle formax_idle_hold_s(600 s by default, set by the operator), and parks transparent leases when another tenant waits for a seat or memory runs short. So0, or a value abovemax_idle_hold_s, does not keep a transparent lease resident. Asuspend_mode: "none"lease is never parked by the plane, only by this timer. See Idle auto-pause.accel.wait_s(optional, number in [0, 120], default 0) makes the create wait for a seat instead of answeringgpu_capacity_exhaustedat once. While waiting, the plane treats you as seat demand (idle residents are suspended for you). You get 201 as soon as a seat is admitted, or the same typedgpu_capacity_exhaustedrefusal withRetry-Afterwhenwait_sexpires. A class that is being rented on demand (gpu_class_provisioning) is waited for the same way: the create places the lease once the node takes leases, or returns that refusal whenwait_sexpires;gpu_class_no_supplyreturns at once. Lease-cap (gpu_lease_cap), credit and fair-share refusals return at once; waiting never helps them. Out of range or non-numeric answers 400. Set your client timeout abovewait_s.- The reply's
templateIDisgpu-lease-v06for the default template and the template name otherwise. Do not pass a top-leveltemplateIDon anaccelcreate — it is ignored there (it is a CPU-plane knob).
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.
| workload | 0.5 GiB | 1 GiB | 2 GiB | 4 GiB |
|---|---|---|---|---|
Free VRAM in a live worker (mem_get_info, context already up) | 248 MiB | 760 MiB | 1784 MiB | 3832 MiB |
| MuJoCo EGL 1080p render (300 frames) | 380 fps | 290-327 fps | 273 fps | (not measured) |
ManimGX manimgx render 1080p60 (23 s video) | 23 s wall | 22 s | 28 s | (not measured) |
- GL/graphics render work fits 0.5 GiB and keeps full GPU acceleration. Removing the GPU made the same ManimGX render 4.1x slower (22.3 s -> 91.5 s) with a byte-identical output file, so the GPU path is real, not incidental: ask for
accel.vram_gb: 0.5and you get it. - Small CUDA jobs run at 0.5 GiB. Measured on production (2026-10-17): 390 of 390 fresh 0.5 GiB leases each ran
x = torch.randn(512, 512, device='cuda'); (x @ x).abs().sum()on their first exec, and 40 concurrent 0.5 GiB leases did the same at once. The largest single tensor that allocated, found by binary search in 8 MiB steps: 248 MiB in a 0.5 GiB slice, 760 MiB in 1 GiB, 1784 MiB in 2 GiB — about 264 MiB of every slice goes to the CUDA context and the allocator. Tier S is 1 GiB, not 0.5, so a named tier starts one step above the smallest slice; start at M (2 GiB) for training-sized allocations.
Exec
POST /sandboxes/{id}/exec
{"code": "<python source>", "timeout_s": 600}
- The body is the source of a Python job, run in the worker's persistent namespace:
torchis pre-bound on torch-backed images, but not onworker_reserve=cudaimages. Two names matter to you —OUTPUT_DIR(a directory whose files become downloadable artifacts, see below) andreport(a small dict returned in the exec reply; keep it under 4 MiB and put real artifacts inOUTPUT_DIR) — and one name is reserved (x; see below). reportis reset to{}at the start of every exec. Other Python variables persist across execs and pause/resume.reportis your data channel:report["exit_code"] = 1still returnsok: trueif the Python job returns normally. Raise an exception to fail the job; a report field alone does not.- The name
xis reserved (the worker's checksummed state canary). A job that deletesxor rebinds it to a non-tensor fails with a contract error naming the reservation — and, because the namespace persists, every later exec on that lease fails the same way. The safe recovery is to destroy and recreate the lease. Use any other variable name;xis a common scratch name, so pick something explicit (loss,batch, …). Rebindingxto a fresh canary of the right kind is the only in-place repair, and the kind is the worker's own: a torch tensor on torch-backed images, but a driver allocation (no torchTensorAPI) on theworker_reserve=cudaimages named above — there a torch tensor passes thenumel()guard yet fails checksum finalization, so recreate the lease instead. timeout_sis the job budget, a number in (0, 3600] (default 120). Past it the job fails AND the lease is killed — CUDA state cannot be safely interrupted mid-kernel, so treattimeout_sas a hard wall and set it above your worst-case runtime. A killed lease isdead: later verbs answer 409/404 (below), and youDELETEit before you create a replacement (see The per-owner lease cap).- One job at a time per lease: a second exec while one is in flight answers 409.
- The reply is
{"sandboxID", "result": {"ok", "report", "stdout", ...}, "meters": {...}}.stdout/stderrare the last 64 KiB of each stream, output of the job's child processes included. Areportthat leaves the result no room for them comes back whole, with emptystdout/stderrand astreams_cutfield that says so. - A Python exception in the job answers 409
lease … job N failed: <Type>: <msg>plus the output tail; the lease survives and stays usable. - Exec on a CPU sandbox id answers 404 (
no control-plane exec for this sandbox id).
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}
cmdis an alternative tocode: send exactly one of them (both, or neither, answers 400).cwd(string) andenv(string-to-string object, merged over the worker's environment) are optional and valid withcmdonly.- It is the same job path, not a second channel: the gateway wraps
cmdinto a fixed Python job that runs it withsubprocess.run(shell=True)inside the worker.cmd/cwd/envare passed as JSON data, never interpolated into source. stdin is closed. - The command's output comes back where a
codeexec's prints do:result.stdoutandresult.stderrhold the last 64 KiB of each stream, andresult.exit_codeis the shell's exit code.result.reportrepeats all three asreport.stdout,report.stderrandreport.exit_code; they are kept for callers that read them there. Like E2Bcommands.run, a non-zero exit is not a failure: the reply isok: truewithexit_code: N. Only a job-level error (409) or a timeout fails the exec. - The directory of the worker's own interpreter (the one that carries torch) leads
PATH, in acmdand in the subprocesses of acodeexec, so the interpreter and the tools installed beside it are found by name. APATHin the request'senvis used as given. - libvgpu, the VRAM-cap library preloaded into every process, prints its own
[HAMI-core Msg(and[HAMI-core Warn(lines to stderr (nvidia-smiends with two); they are dropped fromresult.stderrandreport.stderr, sostderris what your command wrote. Its[HAMI-core ERRORlines, such asDevice 0 OOM, stay. Only stderr is filtered: with2>&1the lines land in stdout. The MCPsandbox_exectool filters thestderrit returns the same way. timeout_ssemantics are identical tocode: overrunning it kills the lease.- Not yet mapped: the E2B envd
process.Process/StartConnect-RPC stream (whatcommands.runspeaks) is not served for GPU lease ids; use this REST form.
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).
- Pause while a job is in flight answers 409 (
suspend only at exec boundaries): pause between jobs. - A lease from a
suspend_mode: "none"template (e.g. themujocoJAX template) is never checkpointed:pauseanswers 200 (phasesuspended) but freezes the whole container (docker pause), so the lease keeps its seat and VRAM, and residency is billed in full (it is exclusive card time — that is the operator's tradeoff for workloads the checkpoint would kill). Resume thaws it. See the pause page.GET /sandboxes/{id}→meters.suspend_modetells you which kind you hold. - On a hard-cap host, where the plane reads each process's VRAM use, a transparent lease is frozen the same way when processes other than its worker hold VRAM (a simulator, game engine or 3D editor your job started) or when that reading is unavailable.
pauseanswers 200 (phasesuspended), the lease keeps its seat and its VRAM, and the frozen time bills in full (meters.frozen_billed_s;meters.frozen_sandmeters.freezescount the freezes). Only a spill bills 0 while paused: on a hard-cap host a transparent lease is spilled only when the reading is available and shows no other process holding VRAM (with no reading it freezes even a worker that has no child processes), and a host without a hard cap has no reading and always spills a transparent lease. Readmeters.freezesfromGET /sandboxes/{id}before and after a pause to see which one you got: it goes up by one for a freeze and stays put for a spill. - Pause of a dead/unknown lease answers 404 or 409 naming the phase.
- An idle lease also pauses itself after
accel.idle_timeout_s(see Create): 600 s by default for a transparent template, off for asuspend_mode: "none"one. It is the same pause as the verb, so it freezes a lease the same way. The next exec or resume thaws it. - Resume needs a free seat: under contention it can answer 429 or 503, always with
Retry-After(see Errors) — retry. An exec or resume of a parked lease gets the same 503 when the node has no host memory to park an idle lease and swap it in; the refusal carries no lease id, only the retry hint.
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)
PUT /sandboxes/{id}/files/{name}(raw body, 512 MiB cap by default) writes into the sameOUTPUT_DIRand answers{"name", "size", "sha256"}: the job reads it atf"{OUTPUT_DIR}/{name}"andGET .../files/{name}returns the same bytes. A job that writes the same name overwrites the upload. Writes need a resident lease: a paused or transitioning lease (an idle-paused one included) answers 409lease is paused or transitioning; resume before uploading, so resume it first; an upload over the cap answers 413.- A job writes its outputs under
OUTPUT_DIR; everything there (regular files only, depth ≤ 4, ≤ 1000 entries) is listable and downloadable by the lease owner.namemay be a relative path (frames/000.png);.., absolute, empty or symlinked components are refused (400/404), and a file over the per-file download cap answers 413 (256 MiB by default). - Reads work while the lease is suspended (no GPU needed) but not once it is dead.
- Same auth/ownership gates as exec; another tenant's lease id is indistinguishable from an unknown one (404).
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 cap counts leases you hold, not seats your leases occupy — a paused (
suspended) lease still counts until it is destroyed, so the way to free a slot is toDELETEa paused lease (or wait for the operator's max-lease-age reaping to reap one). - A
deadlease does not count toward the 8-lease cap: a lease killed by atimeout_soverrun or the VRAM watchdog frees its own slot. It leaves a tier cap too (averifiedkey holds 1): on a node fleet a key at its cap is checked against the nodes holding its leases, a lease its node listsdeador no longer lists stops counting, and a node that cannot be read keeps its leases counted. A dead lease stays registered until youDELETEit. - This is a per-key, per-host ceiling, not platform capacity: a host with free seats still refuses your ninth lease on that host, while a fleet places it on another node that is under its own 8. Capacity is the separate 409/503 shape below.
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).
| slice | extra CUDA contexts admitted | canary | hard cap | usable payload | budget for a second process |
|---|---|---|---|---|---|
| 0.5 GiB | 0 GiB | 0.25 GiB | 1332 MiB | 248 MiB (measured) | 0 MiB (measured) |
| 1 GiB | 0 GiB | 0.25 GiB | 1844 MiB | 760 MiB (measured) | 256 MiB |
| 2 GiB | 0 GiB | 0.25 GiB | 2868 MiB | 1784 MiB (measured) | 1280 MiB |
| 4 GiB | 0 GiB | 0.25 GiB | 4916 MiB | 3832 MiB (measured) | 3328 MiB |
| 8 GiB | 0 GiB | 0.25 GiB | 9012 MiB | 7928 MiB | 7416 MiB |
| 32 GiB | 0 GiB | 0.25 GiB | 33588 MiB | 32504 MiB | 31992 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:
holdkeeps the slice reserved for the whole life of the lease, jobs included. That physical hold is the reason a co-tenant's over-cap burst cannot steal your memory in the tenth of a second before the watchdog kills it: the memory is not free for the taking, so the burst fails instead of landing. The price is that the cached bytes are torch's — a job whose own allocator (MuJoCo Warp, warp, JAX) wants the whole slice can OOM with a driver-levelFailed to allocate … byteseven though the slice is nominally yours.torch.cuda.empty_cache()is not an escape hatch here: it releases blocks cached in the calling process's own allocator, so a child process (an MCPsandbox_execcommand, asubprocess) cannot free what the worker holds.exec(the default) drops the reserve right before your job's code runs and re-tops it after: your allocator sees the full slice for the duration of the job. The peer-protection window is open while it runs — in that window a co-tenant's burst can take the memory, and the watchdog (not your reservation) is what protects the card.
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):
- Each UTC month's billed GPU-seconds are charged to that month's allowance first. The allowance is not cumulative: at 00:00 UTC on the 1st it is full again (2 h verified, 1 h free), whatever was left. It is computed from the billed trail (
gpu_usage.settled_at), so there is no counter to reset and a refill is visible on the first create or usage read of the new month. - Only the part of a month's usage above the allowance draws
gpu_credit_hours, the operator-granted bucket, which never refills. Granted credit is therefore additive: the month rolling over refills the allowance and leaves the granted bucket exactly as spent. Self-serve keys are minted withgpu_credit_hours0; keys without a tier (operator grants) have no allowance and behave as before. - 402 only when both are empty.
GET /v0/usageadds, for a tier key,gpu_monthly_allowance_hours,gpu_monthly_used_seconds,gpu_monthly_remaining_seconds,gpu_monthly_resets_at(epoch) andgpu_monthly_resets_at_iso;gpu_seconds_remainingis allowance plus granted.GET /v1/tiersshowsgpu_hours_monthlyandgpu_leaseson each tier that has GPU, plusgpu_max_vram_gbon the free tier.
What accrues to billed_s:
- Every exec, always — the gateway-observed interval from dispatch to the result landing, charged whether the job succeeded or raised. The worker's self-reported
code_wall_sis diagnostic only and can never lower the bill. - Idle residency, only while the GPU is actually busy. An idle resident lease is sampled for real GPU activity by its processes; only intervals with observed activity are billed (a stray compute thread that outlived its exec). Truly idle time bills 0.
- When the sampler cannot tell (NVML unavailable, query failed, pids unresolvable), the interval is billed only for a
suspend_mode: "none"lease — one that holds the card exclusively, so residency is use. For every other lease the interval is free: a busy-second meter cannot charge a second it could not classify. - Your lease's own resume-toggle time (restoring your state costs card time), each time you resume.
- Frozen time, when a pause froze the lease instead of spilling it (on a hard-cap host, a transparent lease whose processes other than its worker hold VRAM): each frozen interval bills in full (
meters.frozen_billed_s), because the lease keeps its card. - At most
IDLE_TAIL_BILL_S= 1.0 s per idle boundary for an exec's own GPU tail. The activity sampler cannot tell a job's last kernels (its sample window closes just after the job) from a hidden loop, so when the only activity it saw is that tail it bills the idle gap since the exec ended, capped at 1.0 s. A back-to-back client sees about the gap (tens of ms); the cap bounds the worst case. The seconds a pause spends probing VRAM and spilling, and a destroy's container stop, are never part of it.
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}
vram_used_mib/vram_peak_mibare the hard-cap watchdog's latest and maximum attributed readings (the same ones it kills on).sampled_atis the wall-clock time of the latest reading; a value that stops advancing means the watchdog is blind (no NVML) and the numbers are stale.nullmeans not measured, never zero: before the first reading, and always fordevice_nameandutil_pct(no per-lease source exists).classisnullon a host serving several classes.- On a node fleet the front does not receive the watchdog's readings:
vram_used_mib,vram_peak_mibandsampled_atarenullon everyGET /sandboxes/{id}, whilevram_slice_gibis filled in. - A CPU sandbox has no
gpublock. The MCP tools expose no sandbox-info call, so the block is REST only.
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}}
seats_freeis the number of smallest-slice (0.5 GiB) leases a create would be admitted for right now:floor(vram_free_gib / (0.5 + overhead)), whereoverheadis the per-lease hard-cap overhead (0.8 GiB by default, so one 0.5 GiB seat costs 1.3 GiB), capped by the seats left underk_max.vram_free_gibalready has the 1.0 GiB margin and the seated footprints taken off.seats_free: 0means even a 0.5 GiB create is refused withgpu_capacity_exhausted.max_admit_gibis exactly the largest single slice a create is admitted for now:vram_free_gib − overhead, at most 32, and 0 when that is below the 0.5 GiB minimum slice or no seat is left. A create of that size is admitted, anything larger is refused. It isnullwhile the plane has no VRAM reading or runs a fixed seat count, and across a fleet a class reports the largest value of any node (nullif any node is unknown).- The VRAM arithmetic, when the hard cap is on (operator flag, and the default on this plane): each seated lease occupies
slice + 0.8 GiB— the hard-cap overhead, with no extra CUDA-context allowance on top — and a seat exists while the seated footprints fit insidevram_total_gib − 1.0 GiBof margin. Both terms are operator-configurable (GATEWAY_GPU_HARD_CAP_OVERHEAD_GIB,GATEWAY_GPU_HARD_CAP_CONTEXTS, default 0), and with the flag off the footprint is the bare slice. The seat ceilingGATEWAY_GPU_K_RESIDENT_MAXdefaults toauto, derived per node as the smaller of two bounds: the VRAM boundfloor((vram_total_gib − margin) / (0.5 + overhead)), and the host boundmin(floor(MemAvailable at boot / GATEWAY_GPU_HOST_MIB_PER_LEASE), CPUs × GATEWAY_GPU_LEASES_PER_CPU)(defaults 1536 MiB per lease, 4 leases per CPU; a paused lease measured 926–1138 MiB of host RAM on A10/A100). An integer value overrides the derivation, andseats_totalreports the derived value. For example, this plane's 5090 (2026-10-17 reading,vram_total_gib = 30.0703125, sovram_total_gib − margin = 29.0703125with the default margin 1.0 GiB and overhead 0.8 GiB) works out to 22 seats of 0.5 GiB by the VRAM bound alone, capped at the reportedseats_total20; 16 seats of 1 GiB, 10 of 2 GiB, 6 of 4 GiB and 3 of 8 GiB (floor(29.0703125 / (slice + 0.8))— with 40 1-GiB leases held, 16 were seated at once and the rest parked); measured on an A100 40 GB: 29 / 21 / 13 seats at 0.5 / 1 / 2 GiB (operator-measured). GATEWAY_GPU_ADMIT_FLOOR_GB(default 0 = off; an invalid or negative value is 0) is an operator-set host-RAM floor for admission: every live lease runs a worker container that takes host RAM at create time (about 1 GiB per lease, measured on the 62 GiB production node on 2026-10-17), and a parked lease needs more (its slice plus the spill margin and headroom), so a node can admit itself until it swaps and no lease can spill. A create is refused while hostMemAvailable, less every reservation in flight, would be left below the floor after the requested slice: a capacity refusal, 503gpu_capacity_exhaustedwithRetry-Afteron the local plane, and on a node fleet the same refusal every other node capacity refusal takes (409 on the node's internal route, read by the front as a full node and answered to the tenant as the typed 409). One ledger holds every host-RAM consumer the kernel does not show yet: an admitted create reserves its slice until its worker is ready or the create fails, and every spill of device memory to host (a pause, the idle auto-pause, the reaper, an eviction to seat a create) reserves its slice plus the spill margin from its claim until the spill settles. The spill guard reads the same ledger, so concurrent spills cannot overrun the host together. Capacity reports reflect the floor:seats_freecounts only the 0.5 GiB slices that fit in host memory above the floor and the reservations, andmax_admit_gibis bounded by that same remaining memory, rounded down to 0.001 GiB (0 below the minimum slice). A plane whose configured VRAM probe returned nothing keepsmax_admit_gibnull(a create refuses to seat there anyway); a plane with no probe configured keeps the host bound. With the floor off, capacity fields and create admission are unchanged.- A key whose tier does not include GPU gets 403, as on create.
classesisnulluntil the plane has learned its accel classes (the route reads cached state and never runsnvidia-smi).vram_*arenulluntil the plane has taken its first VRAM reading: an unmeasured value is never invented.- A capacity probe, a drain call or a lease list that arrives while a create is launching its container (or a destroy is stopping one) has to wait: it needs the node's admission lock, which the create or destroy holds, and the seat count is not settled until it lets go. A request waits up to 5 s; if the lock is still taken then, or 128 requests are already waiting, it gets the retryable 503
gpu_node_busywithRetry-After: 5. The wait is a poll about every 20 ms, not a queue, so under constant short holds a request can get the 503 although no single hold lasted 5 s. A refused drain has changed nothing, and a fleet front reads the node as not answering that probe. The wait takes no thread: the node keeps answering its other requests, and a request cancelled meanwhile leaves nothing behind. lease_capis per node in a multi-node fleet (largest node value).- A front with several nodes sums nodes per class and counts only nodes that answered (
nodes_unreachable); with none reachable it answers 503. - On a fleet that rents classes on demand, a rentable class with no ready node is listed too, as a row with
"state": "on_demand",boot_eta_s(seconds until a node for it takes leases),provisioning(truewhile a node for it is booting),nodes: 0,seats_total: 0andseats_free: 0. Creating on it answers 503gpu_class_provisioning, not 400. Rows of classes a node serves carry nostatefield.
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:
| setting | what it is |
|---|---|
GATEWAY_GPU_ON_DEMAND | the 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_FILE | the 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_FILE | the 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_FILE | where 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:
- every node that lists it is draining: 503
gpu_node_draining, as above. - 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. - a node of the nodes file is booting for it: 503
gpu_class_provisioning. - the supply file reported
no_supplyfor it at most 600 s ago, and its heartbeat is fresh: 503gpu_class_no_supply,Retry-After: 300,alternativesthe classes served now. - the class is in the catalog: 503
gpu_class_provisioning, and a demand row goes to the provisioner.Retry-Afterand the body'seta_sare the supply file'seta_sfor the class (the time left while it boots one, the measured median while it is idle), else the catalog'sboot_eta_s(no supply file, a stale heartbeat, no row for the class or no number in it), and never below 1. - 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.
| status | meaning | what to do |
|---|---|---|
| 400 | malformed 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 |
| 402 | GPU credit exhausted (free GPU-hours used up) | stop; the bucket is refilled by the operator |
| 403 | key's tier does not include GPU, a free-tier request beyond the trial (gpu_free_tier_limit), or tenant suspended for abuse | the 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 |
| 404 | unknown 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 off | check the id; do not retry blindly |
| 409 | lease-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 plane | read 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 |
| 429 | fair-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 |
| 503 | plane 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 deployment | honor 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 |
| 413 | output file over the download cap | fetch 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:
error | status | extra fields | what to do |
|---|---|---|---|
gpu_capacity_exhausted | 503 (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 header | wait retry_after_s and retry; check GET /v0/gpu/capacity |
gpu_slice_too_large | 400 | param: accel.vram_gb | the 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_budget | 503 (create, once the beta's fleet-wide GPU-hour budget is spent) | retry_after_s (300), class (null), and a Retry-After header | every 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_cap | 409 | none | DELETE 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_exhausted | 402 | none | stop; the bucket is refilled by the operator |
gpu_free_tier_limit | 403 | param: accel.vram_gb, accel.network, accel.class, accel.template or accel.idle_timeout_s | a 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_share | 429 | retry_after_s, class, and a Retry-After header | your key held the GPU past its streak while another waits; wait and retry |
gpu_job_timeout | 409 | none | the 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_dead | 409 (lease died), 503 (exec reply lost; lease destroyed) | none | the lease is gone: DELETE it (a 404 means it is already destroyed), create a new one and re-run the job |
gpu_class_unavailable | 400 | none | accel.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_provisioning | 503 | retry_after_s, eta_s, class, and a Retry-After header | accel.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_supply | 503 | retry_after_s (300), alternatives, class, and a Retry-After header | accel.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_draining | 503 | retry_after_s, class, and a Retry-After header | this 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_unreachable | 503 | retry_after_s, class, and a Retry-After header | the 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_failed | 503 | retry_after_s, class, and a Retry-After header | the 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_busy | 503 | retry_after_s and a Retry-After header; the GET /v0/gpu/capacity answer also carries class | the 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
- No network inside GPU containers by default. Workers run with
--network none: your job cannot reach the internet, pip install, or any host — unless the lease was created withaccel.network: "internet"and the node opted in (operator-sideGATEWAY_GPU_EGRESS=1; disabled-egress creates are refused, never silently downgraded — see Create for the local-route 400, lease-manager 409 and node-front 400 messages, and the transient 503 case). An internet lease reaches public DNS + any public TCP/UDP — pip, Hugging Face, git clone — but NOT the docker host or its gateway ports, RFC1918, CGNAT (100.64/10), link-local + cloud metadata (169.254/16), loopback, multicast, or other leases. Everything else still applies: anonelease's whole environment must be in the template image — customized at build time by the operator (that is the mechanism for adding packages, weights, env). Model weights, when provided, appear read-only at/models; a dataset you need must be shipped in the image, uploaded withPUT /sandboxes/{id}/files/{name}(lands inOUTPUT_DIR), or written by your job'scode. The MCPsandbox_createtool takes the samenetworkargument ("none"|"internet"). - The template list is operator-defined.
mujocoexisting here does not mean it exists on another deployment; there is no way to name an image from a request, ever. Discover withGET /gpu/templates. - Suspend only at exec boundaries, and on a hard-cap host only a lease whose worker is the sole VRAM user releases the GPU. JAX-class workloads run under
suspend_mode: "none"templates, and on a hard-cap host a transparent lease whose other processes hold VRAM is treated the same way: its pause freezes the container and keeps the seat. - One job at a time per lease; a long job blocks the lease's other verbs (pause waits, exec refuses) until it returns.
- A
gl-id is not an e2b SDK sandbox. The SDK path drives CPU sandboxes; lease ids are driven over these REST verbs, or over the MCP toolssandbox_create(templategpu/gpu-lease-v06/mujoco/blender, optionalvram_gb;GET /gpu/templateslists the rest),sandbox_exec(shell command) andsandbox_kill;sandbox_templateslists the catalog. See the MCP connect guide.
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