API reference
One API for every product
Sandboxes, commands, files, images and volumes, with one set of rules.
One API for every Runtime Cloud product, at https://api.withruntime.com. The
SDKs, the CLI and MCP are thin layers over it. This page is the contract; the
machine-readable one is the OpenAPI document at
https://api.withruntime.com/v1/openapi.json, public and generated from the same
route definitions the server validates with.
API version: 0.2.0.
Requests
Send the key as a bearer token. Keys stay in server-side secret storage; never in a URL, a browser or a command line others can read.
Terminalcurl https://api.withruntime.com/v1/sandboxes \ -H "Authorization: Bearer ${RUNTIME_API_KEY}" \ -H "Content-Type: application/json" \ -H "Prefer: wait=60" \ -d '{}'In a shell, put a variable before a colon in braces: "${ID}:exec". zsh, the
macOS default, reads $ID:e as a modifier and sends /xec. Never use
curl -v or --trace with the key: they print the Authorization header.
Terminalcurl -sS "https://api.withruntime.com/v1/sandboxes/${ID}:exec" \ -H "Authorization: Bearer ${RUNTIME_API_KEY}" \ -H "Content-Type: application/json" \ -d '{"command": "python3 -c \"print(6 * 7)\""}'- Resources live at
/v1/<plural>/{id}. A change that is not a plain update isPOST /v1/<plural>/{id}:<verb>::stop,:pause,:wake,:exec. - Field names are camelCase. Times are ISO 8601 in UTC. Money is integer microdollars: 1,000,000 is one US dollar.
- Unknown fields are refused, and every wrong field is named in one answer.
Prefer: wait=N(seconds, at most 120) on a change answers once the resource has settled rather than at once.GET /v1/sandboxes/{id}?waitFor=running&timeoutSeconds=60waits for a state without polling.
Responses and errors
Every response carries x-request-id. Every error has one shape:
JSON{ "error": { "code": "trial_busy", "status": 409, "message": "The trial runs 3 sandboxes at once; ... are running or paused.", "hint": "Stop one (runtime sandbox stop <id>), or pass funding: \"paid\" to use prepaid credit.", "details": { "sandboxIds": ["..."], "concurrent": 3 }, "requestId": "req_0mud1dtgjogtn2z8t6y" }}Follow the hint. Quote the requestId when you report a problem. A 5xx never
carries internal detail, only a fixed message and the request id.
| Status | Codes you may see | Safe next step |
|---|---|---|
| 400 | invalid_request (with details.issues), invalid_region, invalid_trial |
Fix the named fields |
| 401 | unauthorized |
Check the key |
| 402 | trial_exhausted, insufficient_funds, spending_limit_reached |
Add credit, pay with funding: "paid", or read GET /v1/limits |
| 403 | forbidden (a read-only key asking to change something is one), permission_denied |
Do not work around a refusal |
| 404 | not_found, route_not_found, file_not_found |
Check the id or path |
| 409 | no_capacity, trial_busy, not_running, sandbox_paused, sandbox_stopped, sandbox_not_ready, is_a_directory |
Resolve the state, then retry |
| 422 | idempotency_key_reused |
Same key, same body; or a new key for new work |
| 426 | upgrade_required |
Move to this API version |
| 429 | rate_limited |
Wait Retry-After, then retry with the same key |
| 503 | busy, host_unavailable, api_unavailable, guest_busy |
Retry after Retry-After with the same key (the SDKs do) |
| 503 | unavailable, fork_unavailable, previews_unavailable |
Switched off here on purpose; retrying will not help |
The server can refuse capacity; a request is not a guarantee of allocation.
Retries and idempotency
Send Idempotency-Key: <any unique string> on a change. The server remembers the
key for 24 hours:
- The same key with the same body answers the first result again, marked
"replayed": trueand with the headerIdempotency-Replayed: true. Nothing happens twice. - The same key with a different body is refused with 422
idempotency_key_reused. - A new key is new work.
The SDKs send a key on every change and retry transport failures, 429, 502, 503 and 504 with it. Changing the body while reusing a key is not a retry.
Limits
Each key may make about 50 requests a second, in bursts of up to 200, with 128
being served at once; past that, a request waits its turn for up to five seconds
before it is refused. An organization may have 192 requests in flight across all
its keys, and eight terminals open. Over a limit you get 429 with Retry-After. These
are protection, not quotas, and a refusal costs nothing.
Sandboxes
| Method and path | What it does |
|---|---|
POST /v1/sandboxes |
Create. Every field optional. |
GET /v1/sandboxes |
List: state, name, label=key:value (repeat), includeStopped, limit, cursor |
GET /v1/sandboxes/{id} |
Read; waitFor and timeoutSeconds wait for a state |
POST /v1/sandboxes/{id}:stop |
Stop. Compute ends on confirmed shutdown. |
POST /v1/sandboxes/{id}:pause |
Save memory and files; compute billing stops |
POST /v1/sandboxes/{id}:wake |
Restore a paused sandbox; timeoutSeconds sets its next lease |
POST /v1/sandboxes/{id}:extend |
{"seconds": 600} more before the lease ends |
POST /v1/sandboxes/{id}:retention |
{"days": 30} to keep a paused sandbox, 1 to 365 |
POST /v1/sandboxes/{id}:restart |
Start a stopped persistent sandbox again from its disk |
The create body:
| Field | Default | Notes |
|---|---|---|
name, labels |
none | Your own handle and up to 32 key: value tags |
funding |
trial while it lasts, then paid | "trial" never falls back to paid credit |
region |
the default region | Use a region listed for your account |
vcpu |
2 | Sizes are subject to the deployed host's measured capacity |
memoryMiB |
4096 | |
diskMiB |
4096 | |
cpu |
"shared" |
"reserved" guarantees every vCPU |
cpuFloorMillis |
50 | Guaranteed CPU while shared, in thousandths of a vCPU |
timeoutSeconds |
1800 | How long it may run before its lease ends, at most 3600 |
onLeaseEnd |
"pause" |
Or "stop" |
pausable |
true | |
network |
the public web on ports 80, 443 | Same shape as PUT /v1/sandboxes/{id}/network |
maxCostMicros |
none | Refuse the create if its first lease would cost more |
image, volumes |
none | A ready image id and up to four {volumeId, path, mode} |
snapshot |
none | A ready snapshot id: start as a copy of it. Not with image |
Sizes are subject to the deployed host's measured capacity. Sizes are limits you ask for; the server checks their combinations, account limits and available host capacity.
Commands and processes
| Method and path | What it does |
|---|---|
POST /v1/sandboxes/{id}:exec |
Run and answer {exitCode, stdout, stderr, timedOut} |
POST /v1/sandboxes/{id}/processes |
Start in the background; add "pty": {"cols", "rows"} for a terminal |
GET /v1/sandboxes/{id}/processes |
Running and recent processes |
GET /v1/sandboxes/{id}/processes/{processId} |
One process |
GET /v1/sandboxes/{id}/processes/{processId}/output |
Output from cursor; waitMs long-polls; follow=true streams |
POST /v1/sandboxes/{id}/processes/{processId}:write |
{"data"} or {"base64"}, with offset and eof; offsets make retries safe |
POST /v1/sandboxes/{id}/processes/{processId}:signal |
{"signal": "SIGTERM"} |
POST /v1/sandboxes/{id}/processes/{processId}:resize |
{"cols", "rows"} for a pty |
The exec body is command (run under bash -c) or argv (no shell), and
optionally cwd, env, stdin, timeoutMs (default 60,000; at most 24 hours)
and stream. With "stream": true the answer is NDJSON, one event a line:
start, stdout, stderr, exit, and continue with a cursor when a stream
passes the server's time limit, from which …/output?follow=true&cursor= resumes.
A timeout is a result with timedOut: true, not an error. env values are never
echoed and are stored only as hashes.
Terminals
GET /v1/sandboxes/{id}/terminal with Upgrade: websocket and the bearer header
opens a terminal: cols, rows, command (default bash -l), cwd, or
processId to attach to a process started with a pty. Binary frames are terminal
bytes both ways. Text frames are JSON: you send {"type":"resize","cols":120,"rows":40};
the server sends {"type":"ready","processId":...} and {"type":"exit","exitCode":...}.
Files
| Method and path | What it does |
|---|---|
GET /v1/sandboxes/{id}/files/content?path= |
The file's raw bytes |
PUT /v1/sandboxes/{id}/files/content?path= |
Replace the file with the raw request body; makes parents |
GET /v1/sandboxes/{id}/files/list?path= |
Entries; depth, glob, hidden, limit |
GET /v1/sandboxes/{id}/files/stat?path= |
Type, size, mode and times, or {"exists": false} |
POST /v1/sandboxes/{id}/files:mkdir |
{"path", "parents"} |
POST /v1/sandboxes/{id}/files:remove |
{"path", "recursive"} |
POST /v1/sandboxes/{id}/files:rename |
{"from", "to", "overwrite"} |
POST /v1/sandboxes/{id}/uploads |
Begin a large upload: {"path", "size", "sha256"} |
PUT /v1/sandboxes/{id}/uploads/{uploadId}?offset= |
One chunk of raw bytes; chunks may go in parallel |
POST /v1/sandboxes/{id}/uploads/{uploadId}:commit |
Check the digest and move the file into place atomically |
POST /v1/sandboxes/{id}/uploads/{uploadId}:abort |
Give up |
Paths are absolute. File errors name the path: file_not_found,
is_a_directory, not_a_directory, permission_denied.
Other products
| Method and path | Product |
|---|---|
POST /v1/sandboxes/{id}/interpreter:run, …/interpreter/contexts |
Code interpreter |
POST, GET /v1/sandboxes/{id}/previews, …/previews/{port}, …/previews/{port}:rotate |
Previews |
POST /v1/sandboxes/{id}/desktop:start, :stop, :act, GET …/desktop/screenshot |
Desktop |
GET, PUT /v1/sandboxes/{id}/network |
Network rules |
POST, GET /v1/feedback |
Feedback |
POST /v1/support/messages, GET /v1/support/conversations/{id} |
Support |
GET /v1/me, GET /v1/usage, GET /v1/limits |
Account |
POST, GET /v1/images, GET /v1/images/{id}, …/logs, :delete |
Custom images |
POST, GET /v1/volumes, GET /v1/volumes/{id}, :delete |
Volumes |
POST /v1/sandboxes/{id}:fork, POST /v1/sandboxes/{id}:snapshot, GET /v1/snapshots, …/{id}, :extend, :delete |
Forks and snapshots |
The OpenAPI document has every field of every route.
Account
GET /v1/me says who a key is: orgId, principalId, credentialId.
GET /v1/usage says what the account has and has used. Money is integer
microdollars in strings (1,000,000 is one dollar), exact however large:
| Field | What it is |
|---|---|
credited |
Every credit ever added: purchases and grants |
spent |
Settled usage, including what refunds and disputes took back |
held |
Reserved against running and paused work, not yet settled |
expired |
Credit that expired, or grant credit taken back |
available |
What can still be spent: credited - spent - expired - held |
takenBack |
The part of spent that refunds and disputes took |
trial |
{totalMs, usedMs, reservedMs, availableMs}, or null |
resources |
The newest hundred resources, with what each used and was charged |
GET /v1/limits says what this key may do and what its agent may still spend:
JSON{ "access": "full", "daily": { "limitMicros": "25000000", "usedMicros": "3100000", "remainingMicros": "21900000", "window": "24h" }}access is full (every action in every product), read (a read-only key:
every read, no change and no spending) or selected (the actions the key
names). daily.limitMicros is the most the key's agent may commit in any 24
hours, or null when the owner set no limit. usedMicros counts settled charges
plus money still on hold. Past the limit, a create, wake, extension or renewal
fails with 402 spending_limit_reached and charges nothing. Only an account
owner sets, changes or removes a limit, at
API keys; no key can.
Pages
Every list, in every product, answers {"data": [...], "nextCursor": "..."}.
Pass nextCursor back as cursor; null ends the list. limit is 1 to 100.
Versions
This is 0.2.0. The 0.1.0 routes under /v1/resources were removed on
22 September 2026: each answers 410 upgrade_required, and details.replacement
names the route that replaced it. The migration guide maps each old
call to its new one. The OpenAPI document's info.version is the version you are
talking to.