Skip to content
Markdown

HTTPS API reference

One API for every Runtime Cloud product, at https://api.withruntime.com.

The SDKs, the CLI and MCP are thin layers over this API. This page is the contract. The machine-readable contract is the public OpenAPI document at https://api.withruntime.com/v1/openapi.json. It is generated from the same route definitions that the server validates with.

API version: 0.2.0.

Updated

On this page

Requests#

Send the key as a bearer token. Store keys in server-side secret storage. Never put a key in a URL, a browser, or a command line that others can read. A browser uses a session instead.

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. It then sends /xec.

Never use curl -v or --trace with the key. They print the Authorization header.

A key sees and uses only what its own agent made. Another key's sandbox returns 404 not_found. An account-wide key sees and uses everything in the account. An owner or admin makes one at API keys, or approves one with runtime keys create --account-wide. Such a key's getOrCreate can return a sandbox that another key named. A key in a compartment sees only what its compartment's keys made. A full-access key of an owner or admin can make, list and revoke keys with /v1/keys. No other key can (run a swarm).

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(sum([125, 250, 375]))\""}'
  • Resources live at /v1/<plural>/{id}. A change that is not a plain update uses POST /v1/<plural>/{id}:<verb>, such as :stop, :pause, :wake or :exec.
  • Field names are camelCase. Times are ISO 8601 in UTC. Money is an integer count of microdollars: 1,000,000 is one US dollar.
  • Unknown fields are refused. One response names every wrong field.
  • Prefer: wait=N (seconds, at most 120) on a change returns once the resource has settled, not immediately. GET /v1/sandboxes/{id}?waitFor=running&timeoutSeconds=60 waits for a state without polling.

Responses and errors#

Every response includes an x-request-id header and a Server-Timing header, app;dur=12.3: the time in milliseconds from when the API received the request until its response headers were ready. Subtract it from the time your client measured to get the network's share. Every error has one shape:

JSON{  "error": {    "code": "no_credit_running_limit",    "status": 429,    "message": "An account without credit runs at most 4 sandboxes at once.",    "hint": "... Stop or pause one you no longer need (runtime sandboxes stop <id>, or pause <id>), or wait for one to end, then retry. ... Adding credit lifts this. Only the account's owner adds credit, at https://withruntime.com/account/billing#add-credit, so tell them what was refused; an agent never adds or spends it on its own. Credit added within 30 days of signing up is matched, up to $1,000 in total. ...",    "details": {      "running": ["..."],      "addCredit": {        "url": "https://withruntime.com/account/billing#add-credit",        "by": "owner",        "givenCreditCounts": true,        "welcomeMatch": { "withinDays": 30, "upToUsd": 1000 }      }    },    "requestId": "req_0mud1dtgjogtn2z8t6y",    "retryable": true  }}

Follow the hint. Quote the requestId when you report a problem. A 5xx response never includes internal detail. It contains only a fixed message and the request ID.

retryable says whether to send it again. Every error has it. true means the same request, sent again unchanged, may succeed. Such a response always has a Retry-After header with the seconds to wait first. The body also has retryAfterMs when the wait is exact. false means something must change first. The same request gets the same response, and that response never has Retry-After. Each code has one status and one retryable value. They are listed below and in the OpenAPI document's x-error-codes. A code is never renamed, so code that reads code keeps working.

A refusal that adding credit lifts has one shape, whatever the product. An agent can then tell its person plainly what to do. The code is one of no_credit_size_limit, no_credit_running_limit, no_credit_total_limit, no_credit_kept_limit, no_credit_daily_limit, no_credit_key_limit, no_credit_domain_limit, no_credit_capacity_full, no_credit_build_limit, included_usage_used_up, included_usage_unavailable, insufficient_funds, payment_required, public_preview_not_allowed or connect_not_allowed:

  • message: what was refused, and the limit.
  • hint: what you can do now without credit. Then it gives the one way past the limit. The account's owner adds credit at Usage & billing. Credit you add within 30 days of signing up is matched, up to $1,000 in total (welcome match).
  • details.addCredit: the same, for code. givenCreditCounts is false where only paid credit lifts the refusal. That covers public previews, ports beyond 80 and 443, custom domains, public TCP ports and private networks. welcomeMatch holds the match's days and dollars.

An agent never adds or spends credit on its own. It tells the owner which limit it met. One exception is an account that the agent opened itself with a wallet. No person pays for that account (pay with stablecoins). For that account, the hint says to add credit yourself. Use npx withruntime billing topup <usd> or POST /v1/billing/topups with the account's own key. In that case, details.addCredit is {"by": "agent", "command": ..., "api": ..., "givenCreditCounts": ...} and has no url. GET /v1/limits shows which kind of account a key is on (credit.addedBy).

A claim code is the way back into such an account if its key is lost. Use npx withruntime billing claim <code> --new-key, or POST https://withruntime.com/api/wallet/rekey with {"claimCode": "..."}. Either returns a new key, shown once. Every other key of the account stops working immediately. A claim code replaces the key at most three times in 24 hours. Each replacement is recorded in the account's audit log (wallet.key_replaced). This works only until a person takes the account over. After that, its people make keys on the website. Keep the claim code as secret as the key. connect_not_allowed returns no addCredit for an organization whose port access Runtime turned off. Credit does not change that refusal.

A 5xx says whose fault it is. A message that says the fault is ours, not your request's, was reported to us automatically, with its requestId. A 500 is a fault that a retry does not fix. A 502, 503 or 504 clears on its own, so retry it with the same key. Write to us only if it keeps failing. A 501 means a product is switched off on this deployment.

The status follows HTTP's meaning. A client or retry library that reads only the status does the right thing. 409 means a state you must change first, such as a sandbox to wake or a name already held. It can also mean a state that clears by itself. 429 means your own limit: a rate, what runs at the same time, a quota, or a daily cap. 503 means the service is briefly away or out of room.

Status Retryable Codes Safe next step
400 no confirmation_required, copies_invalid, cursor_ahead, disk_shrink, env_invalid, env_name_reserved, env_too_large, image_no_command, invalid_alert, invalid_archive, invalid_authority, invalid_backup_night, invalid_drill, invalid_env, invalid_host, invalid_journal, invalid_lease, invalid_packages, invalid_path, invalid_region, invalid_request, invalid_service, port_ambiguous, resize_needs_restart, scale_invalid, size_exceeded, switch_keeps_workspace_only, terms_required, volume_invalid, working_dir_invalid Fix the request as the message and details say
401 no invalid_identity, unauthorized Check the key
402 no account_blocked, budget_exceeded, budget_reached, included_usage_unavailable, included_usage_used_up, insufficient_credit, insufficient_funds, payment_failed, payment_required, pilot_ended, pilot_hours_used, retention_unavailable, spending_limit_reached It needs credit or money; the message says which, and details.addCredit how
403 no agent_key_required, connect_not_allowed, forbidden, host_mismatch, mode_not_applied, network_not_allowed, no_person, outside_compartment, permission_denied, plan_required, product_in_beta, public_preview_not_allowed, sandbox_halted, secret_not_shared, sign_in_unfinished, signed_requests_off, sso_refused, sso_required, wrong_source This key or account may not; do not work around it
404 no file_not_found, image_not_found, not_available, not_found, path_not_found, process_not_found, route_not_found, sandbox_gone, snapshot_not_found, target_not_found, transfer_not_found, volume_not_found Check the id, name or path
405 no method_not_allowed, wrong_method Use a method the path accepts
408 no download_idle Send the request again, faster
408 yes request_timeout Send it again after Retry-After, with the same key
409 no already_exists, already_switched, already_topped_up, archive_unavailable, auto_wake_off, blobs_missing, browser_failed, call_dismissed, call_started, checksum_mismatch, chunk_conflict, compartment_not_empty, conflict, desktop_not_running, directory_not_empty, file_trace_not_requested, file_trace_unavailable, fork_failed, guest_upgrade_required, image_in_use, image_unchanged, image_unnamed, insufficient_storage, invalid_state, is_a_directory, journal_full, legacy_email_requires_reconciliation, limit_reached, memory_full, name_conflict, name_taken, no_credit_kept_limit, no_credit_key_limit, no_memory_scope, no_space, not_a_directory, not_a_regular_file, not_a_terminal, not_installed, not_pausable, not_paused, not_resumable, not_running, offset_mismatch, operation_conflict, operation_outcome_unknown, payment_used, process_running, rebase_copy_failed, rebase_copy_timeout, repository_disconnected, repository_taken, resize_disk_cold, resize_needs_disk, resource_stopped, restart_unavailable, rollback_unavailable, root_unavailable, sandbox_in_use, sandbox_not_paused, sandbox_not_running, sandbox_paused, sandbox_stopped, second_step_already_on, second_step_not_started, second_step_off, second_step_required_by_account, secrets_mismatch, service_conflict, service_resume_unavailable, session_ended, session_open, session_stopping, size_unchanged, snapshot_metadata_changed, snapshot_unsupported, stdin_closed, switch_needs_disk, tailscale_joined, time_limit_too_short, trigger_paused, unmount_failed, unsupported_operation, upload_incomplete, version_conflict, volume_attached, volume_change_refused, webhook_disabled Change the state the message names (wake, stop, detach, rename), then send it again. A count you hold at its limit (no_credit_kept_limit, no_credit_key_limit, limit_reached, and quota_exceeded on what is kept: volumes, images, snapshots, secrets, kept sandboxes) is one too: delete one, or raise the limit; sending it again alone does not help
409 yes backup_not_ready, browser_installing, desktop_installing, file_changed, image_building, image_not_ready, operation_pending, resize_during_snapshot, resize_not_halted, run_in_progress, sandbox_moving, sandbox_not_ready, snapshot_capturing, snapshot_in_progress, snapshot_not_ready, switch_not_paused, volume_not_ready, volume_releasing Clears by itself: send it again after Retry-After, with the same key
410 no deleted, host_lost, link_expired, link_revoked, name_retired, secret_revoked, snapshot_expired, snapshot_lost, upgrade_required Gone for good: make a new one, or use the name the hint gives
413 no body_too_large, file_too_large, too_large Send less, or use the chunked upload
415 no unsupported_media_type Send Content-Type: application/json
416 no range_not_satisfiable Ask for a range inside the file; the message says its length
422 no archive_failed, cwd_not_found, disk_too_small, idempotency_conflict, idempotency_key_reused, image_build_failed, image_config_invalid, image_limit_too_small, image_on_another_host, image_platform_unsupported, image_port_invalid, image_registry_unsupported, interpreter_failed, invalid_volume_attachment, keep_env_refused, mcp_cleanup_failed, mcp_failed, no_credit_size_limit, passkey_limit, passkey_refused, recording_failed, ref_not_found, retention_too_long, scale_over_limit, second_step_wrong_code, subagent_depth, tailscale_failed, tunnel_overrun, tunnel_unavailable, unprocessable, upload_failed, volumes_apart, volumes_on_different_hosts Well formed, but not possible as asked: change what the message names; a program in your sandbox that failed is named in details
429 yes account_share_full, blob_store_full, blob_upload_limit, build_in_progress, context_limit, daily_limit, guest_busy, no_credit_build_limit, no_credit_daily_limit, no_credit_domain_limit, no_credit_running_limit, no_credit_total_limit, process_limit, quota_exceeded, rate_limited, resource_limit, sandbox_busy, second_step_locked, startup_application_limit, startup_cool_down, subagent_quota, transfer_limit, trial_signup_rate, watch_limit quota_exceeded for a limit on what runs at once (details.limit sandbox.count, sandbox.vcpu, sandbox.memory_mib, sandbox.disk_mib, tunnel.count or instance.count); on what is kept it is a 409. Your own limit: its rate, what runs at once, a quota or a daily cap. Retry after Retry-After with the same key, or end something it counts
451 no taken_down Taken down after a report; write to support if it was a mistake
499 no cancelled, client_closed, request_cancelled You closed the connection first; nobody read this
500 no env_unreadable, guest_quarantined, history_unreadable, internal_error, job_failed, operation_failed, price_unavailable, secrets_unconfirmed, snapshot_busy, snapshot_unusable, watch_failed Our fault, reported to us. Safe to send again with the same key, but it may fail the same way until we fix it
501 no delete_unavailable, env_unavailable, feature_unavailable, file_links_unavailable, fork_unavailable, identity_unavailable, network_rules_unavailable, network_unavailable, previews_unavailable, resize_unavailable, schema_migration_required, secrets_unavailable, storage_unavailable, switch_image_unavailable, switching_paused, unavailable, unsupported Switched off on this deployment; sending it again does not help
502 yes billing_unavailable, download_incomplete, github_unavailable, guest_failed, image_unavailable, keys_unavailable, support_unavailable, upstream_failed A service behind the API answered badly: retry after Retry-After with the same key
503 yes alert_in_flight, alert_unavailable, api_unavailable, base_image_unverified, body_stalled, builder_outdated, busy, capacity_unavailable, copies_unread, desktop_connection_unavailable, drill_unalerted, drill_unrecorded, email_unavailable, guest_not_ready, heartbeats_unreadable, history_unavailable, host_busy, host_capacity, host_memory_pressure, host_not_ready, host_start_failed, host_unavailable, host_unknown, monitor_unavailable, no_capacity, no_credit_capacity_full, planned_release, resize_not_started, sandbox_environment_unavailable, sandbox_unreachable, secret_withheld, signing_unavailable, snapshot_restoring, switch_undone, wake_refused Briefly away or out of room: retry after Retry-After with the same key (the SDKs do)
504 yes host_timeout, upstream_timeout A service behind the API did not answer in time: retry after Retry-After with the same key

Retryable answers are safe to send again. no_capacity, busy, host_unavailable, rate_limited, no_credit_running_limit, and every other code marked retryable above clear on their own. Retry with the same key after Retry-After, using a growing delay and a bounded deadline.

  • The SDKs retry every response that says retryable: true themselves. They wait for its Retry-After, up to maxRetries times. The exceptions are no_capacity, quota_exceeded and no_credit_running_limit. The SDKs wait those out only where they wait for room (below). Otherwise they return the error immediately. A wait over a minute, such as a daily cap, is not slept on. The error is returned immediately, with retryAfterMs, so you can schedule the retry.
  • guest_busy, busy and rate_limited mean nothing ran. The SDKs send such a call again, with the same key. The delay grows from Retry-After up to eight times that value. They keep going until the call's own deadline. If the call has none, they keep going for five minutes. This applies however many retries maxRetries allows. Then the refusal itself is returned. A sandbox under heavy load can return guest_busy for tens of seconds. The call is then slow, not failed.
  • A quota_exceeded names the limit in its message and in details. details has limit (such as volume.count), max, and how to raise it. Every limit is listed in limits.
  • A create or wake refused with no_capacity keeps its place in its region's line. It keeps the place if it is sent again with the same Idempotency-Key (the SDKs do this). Its message gives the place, how many wait ahead, and the account's lane. For example: "in line at place 2: the region's room goes to higher lanes first: 1 waiting ahead (pro 1); this account's lane is free". Its details.line holds the same as lane, place, ahead (by lane) and words. Freed room goes to the highest lane waiting first. See waiting in line for room.
  • An SDK create also waits out no_credit_running_limit, quota_exceeded and no_capacity. It waits up to two minutes by default, then returns the refusal. For a loop of your own, the error's retryable is true for three cases. Those are no_credit_running_limit, no_capacity, and a quota_exceeded on what runs at the same time (sandbox.count, sandbox.vcpu, sandbox.memory_mib, sandbox.disk_mib, tunnel.count, instance.count). A quota_exceeded on what you keep (volumes, images, snapshots, secrets, kept sandboxes) is a 409. It says retryable: false and has no Retry-After. It clears only when you delete something or raise the limit. No SDK waits for it.
  • no_capacity is a 503 with Retry-After: 1. no_credit_running_limit and a retryable quota_exceeded are 429s with Retry-After: 5. That value is the wait before a first retry, for a client that reads only HTTP. The SDKs keep their own growing delay. Until 17 October 2026, all three were 409s with the same body.
  • no_credit_running_limit clears when one of the four running sandboxes without credit stops or pauses. A fork that asks for more copies than that never fits.

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 returns the first result again. The result is marked "replayed": true, and it has the header Idempotency-Replayed: true. Nothing happens twice.
  • A secret or token shown once is left out of the replay. Examples are a rotated webhook secret, a preview or MCP token, and a WireGuard key that Runtime made. Read the resource for a fresh token. A first response over 32 KiB is not kept. Its replay returns 409 operation_conflict, and nothing runs again.
  • The same key with a different body is refused with 422 idempotency_key_reused.
  • A refusal is not kept. A request refused before anything ran (no_capacity, busy, or a limit) leaves its key unused. So the same key sent again runs the request afresh. Only a response that did the work is replayed. A create refused with no_capacity starts the sandbox when it is retried with its key once there is room. The next retry with that key replays it.
  • no_capacity is a 503 with Retry-After wherever it is returned. That covers a create, a fork, a restart, a wake, a public port, and a dedicated address. So a client that retries 5xx retries it, with the same key.
  • A wake of a paused sandbox that is moving to another server returns 409 sandbox_moving with Retry-After until its paused memory and disk are copied there, usually within a minute or two. Nothing changed, and the sandbox stays paused with its files and memory. Send the wake again with the same key. The SDKs wait for it as they wait for no_capacity, and a call that wakes a paused sandbox returns the same 409.
  • A waited pause (POST /v1/sandboxes/{id}:pause with Prefer: wait) succeeds when its server's snapshot space is full. The server holds the sandbox's memory and saves it once there is room. Only a sandbox too near the end of its time to save later returns no_capacity (503, Retry-After: 60). The pause was not taken. The sandbox is still running, with its memory and files. Unlike a create, that request did run. So pause again with a new Idempotency-Key. The same key only returns the sandbox as it is now.
  • A new key is new work.

The SDKs send a key on every change. They retry transport failures, and every response marked retryable: true, with that key. A connection refused before the request left is sent again immediately. Anything that may have reached a server waits first. Changing the body while reusing a key is not a retry.

Limits#

These limits protect the service. They are not quotas. A refusal costs nothing.

  • Per key: about 50 requests a second, in bursts of up to 200. Up to 128 are served at the same time. Past that, a request waits its turn for up to five seconds before it is refused. A key may have as many served at the same time as its organization allows (below), when that is more. Its rate and burst grow by the same share. For example, an organization holding 40 sandboxes has 432 in flight. So each of its keys may send about 170 a second in bursts of 675. It may send at most 400 a second in bursts of 1,600.
  • Per address, for keys not yet seen to work: until a request on a key is accepted once, requests on that key also count against the address they come from. So do requests with wrong or missing keys. The limit is about 50 a second, in bursts of 200 (address_rate). A key that has worked is never refused because of someone else's wrong keys.
  • Per organization: 192 requests in flight across all its keys. Add six for each sandbox it holds that is not stopped, up to 1,024. When support has raised its sandbox quota past 138, add six for each running sandbox up to that quota (2,592 for 400 running). That lets every sandbox stream four commands and two watches at the same time. A new sandbox's six come within half a minute. The organization also has 16 watch streams, 16 SSH logins and port forwards, and 64 terminals open. Each adds two more for each such sandbox. That gives up to 292 watch streams, 292 logins and forwards, and 340 terminals, or more as the quota allows. A pilot's agreed count sets more too. A sandbox has at most 12 terminals. When the service as a whole holds as many as it can, a new request is refused for a moment with a retry delay. Requests beyond what an organization's sandboxes bring share one room. While that room is three-quarters taken, an organization holding its fair share waits for its own requests to finish (organization_fair_share). So a busy account never crowds out the rest.
  • Per sandbox: up to 1,024 commands and background processes can run at the same time. The sandbox's memory is usually the closer limit. Past that, a new one is refused with process_limit (429), which names the number. A command's output stays readable through its own stream, however many others end meanwhile. Processes and threads together are limited to 16,384 in a sandbox. One command and all it starts are limited to 15,360. So a fork bomb in one leaves room for the command that ends it. Past that, Linux refuses a new one: fork or pthread_create fails with EAGAIN. A custom image keeps the limits of the day it was built. An image built while the command limit was 16 keeps 16 until it is built again.
  • Per address: 64 connections open to the API at the same time, and 50 new ones a second in bursts of 100. Once a request from the address carries a valid key, the address may hold its organization's room instead, for the next 10 minutes. Each such request renews that period. The room is 256 connections and 200 new ones a second. It is larger for an organization with more requests in flight. Past a limit, a new connection gets no response. The client's own retry brings it in a second or so later, once a connection closes or the address has room. A streaming response (a command's output, a download, or a watch) holds its connection until it ends. The SDKs hold each client to 48 connections, for calls and streams together. They keep 8 of them for calls and queue the rest. So a client waits for a connection rather than fail. For example, a thousand streamed commands at the same time from one client are read 40 at a time.

Over a limit, you get 429 with Retry-After. Streaming responses count as requests in flight until their bodies finish or you cancel them. Receiving the headers does not release a request slot. A request you abandon counts until its work stops. So does a request that returns 503 because it ran past its deadline (110 seconds from the end of its request body). Such a request is stopped. It counts against its key and organization until its work has stopped. A read stops immediately. A change stops once it is recorded. That takes at most about four minutes more. A command it started keeps running in the sandbox.

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. Returns immediately, where compute billing ends; a kept disk is written out after
DELETE /v1/sandboxes/{id} Delete for good, in any state (see "Deleting a sandbox" below)
POST /v1/sandboxes/{id}:pause Returns once the processors stop, where compute billing ends; memory and files are then saved
POST /v1/sandboxes/{id}:wake Restore a paused sandbox; for one with a time limit, timeoutSeconds sets the new one
POST /v1/sandboxes/{id}:extend {"seconds": 600} more before its time limit; a sandbox with none returns immediately, unchanged
POST /v1/sandboxes/{id}:retention {"days": 30} keeps a paused or stopped sandbox 1 to 365 days; null, the default, while you have credit
POST /v1/sandboxes/{id}:restart Start a stopped sandbox from its kept disk, or reboot a running one from its disk
POST /v1/sandboxes/{id}:update Change name, labels, env, autoWake, wakeOnTimers, user, leaseSeconds, idlePauseSeconds, persistent, maxTotalCostMicros, tailscale (null leaves), timeoutSeconds, onTimeout
POST /v1/sandboxes/{id}:switch-image Move it to another image, keeping /workspace (see "Switching its image" below)

The create body:

Field Default Notes
name, labels none Your own handle and up to 32 key: value tags
env none Environment variables for every command, process, terminal, SSH session and image start command in it (see "Its environment" below)
funding ignored Accepted and ignored: the free usage is spent first, then prepaid credit, and an account that holds credit gets paid limits
region the default region Use a region listed for your account
vcpu 2 At most 16 on a paid sandbox, 2 without credit
memoryMiB 4096 At least 128; at most 65,536 (64 GiB) on a paid sandbox, 4,096 without credit; a size outside that is refused with invalid_request naming the field. An odd size is rounded up to the next even number (4095 becomes 4096), which is the size it gets, is billed at and is returned; resize rounds the same way
diskMiB 4096 From an image of yours larger than that, 8,192 (8 GiB) when it holds the image; a larger image is refused with disk_too_small, naming the diskMiB to ask for. At least 3,072; at most 409,600 (400 GiB), and 10,240 without credit. A larger disk is refused with invalid_request, naming the maximum. A sandbox is placed only on a server with that much free disk
cpu "shared" "reserved" guarantees every vCPU: the same as cpuFloorMillis at vcpu x 1000, which it sets, ignoring any other cpuFloorMillis; not without credit
cpuFloorMillis 50 Guaranteed CPU, in thousandths of a vCPU: at most vcpu x 1000 (16,000 at 16 vCPU), and 250 without credit. The highest, vcpu x 1000, is what cpu: "reserved" sets
timeoutSeconds 0, none A time limit you choose, 60 to 86,400 seconds (24 hours): it pauses or stops when that runs out, busy or not. Left out (or 0), it has none: it runs while it works and pauses when idle, until you stop it or credit or free usage runs out. A large sandbox needs time to wake its memory: 190 s for 65,536 MiB; shorter is refused, naming the least
onTimeout, onLeaseEnd "pause" "pause" keeps memory and files when its time limit, credit or free usage runs out; wake it later. "stop" stops it and keeps its disk for :restart; "delete" stops it and deletes its disk. onLeaseEnd is its older name
idlePauseSeconds 300 Pause after this many seconds with nothing happening: no request, no command or terminal running, no open connection, no traffic and no CPU use. 0 is never, otherwise 60 to 86,400. Not set on a persistent sandbox
autoWake true A request to a paused sandbox wakes it (see below)
wakeOnTimers true A process due on a timer wakes it when it paused for being idle (timers). Needs autoWake
user "sandbox" "root" runs its commands, background processes and terminals as root, through its passwordless sudo, unless one names user: "sandbox"; files written through the files API and SSH logins stay the sandbox user's. A sandbox made from a snapshot runs as its source did
leaseSeconds its first lease, 1,800 With no time limit, how far ahead each renewal funds it: from its first lease (1,800 seconds unless the sandbox was made with a shorter one, at least 300) to 43,200 seconds (12 hours). It is renewed when half is left, so it keeps running for at least half of this if Runtime's control plane cannot renew it. Each renewal holds that much time's credit; what it does not use is released. A sandbox on the free usage renews by its first lease until the account has credit. Set only with an owner's, an admin's or an account-wide key, on a sandbox one of those made (403 otherwise); if it loses that right, a sandbox funded past its first lease is paused or stopped at once
persistent false Paid only: it runs until you stop it, while credit lasts, with no idle pause unless you set one. Its disk is billed as any sandbox's
maxTotalCostMicros none The most the sandbox may cost over its whole life
maxCostMicros none Deprecated, still accepted from older clients: use maxTotalCostMicros
pausable ignored Deprecated, still accepted from older clients and ignored: every sandbox can pause, fork and be snapshotted. One created with pausable: false before 5 October 2026 can only be stopped; a pause, fork or snapshot of it returns not_pausable
getOrCreate false With name: return the sandbox that holds the name (see below)
network every public port (paid), 80 and 443 (without credit) Same shape as PUT /v1/sandboxes/{id}/network
image, volumes none A ready image (its id, name, name:tag or name@version) and up to four {volumeId, path, mode}: mode "rw" (default) or "snapshot", a read-only copy of the volume for any number of sandboxes, unrelated to sandbox snapshots (storage). An image with a start command returns once its ready check passes, in start
snapshot none A ready snapshot id: start as a copy of it. Not with image
spreadFrom none One to 16 ids of your own sandboxes or snapshots: it starts on a server none of them is on (see "Keeping copies on several servers" below). Not with an id this key may not read: that is 400 invalid_request
evictable false Runtime may delete it once it has been paused or stopped for at least 15 minutes and its server needs the disk (see "Evictable sandboxes" below)
secrets every one that reaches it The egress secrets it may use, by name, at most 64: only those of your secrets that would otherwise reach it, never one a secret's only keeps from it. [] is none; a name not stored yet takes effect once stored. Fixed for its life; a fork or a copy of a snapshot keeps its source's list and may only narrow it. A read returns it as secrets (null for none named). See secrets sandboxes never see
tailscale none Paid only: {authKeySecret, hostname?, tags?} joins your Tailscale network once it runs (see "On your tailnet" below)

Sizes are limits you ask for. The server checks their combinations against account limits and the host's measured capacity.

Placement. Every sandbox and snapshot response has placement. It is an opaque tag such as pl_3f9a0c1b2d4e5f60, or null before the sandbox is placed. Your sandboxes and snapshots with the same tag are on the same server. The tag is never the server's name. Another account's tags are unrelated to yours.

Wake on request. A paused sandbox with autoWake wakes by itself when a command, file, process, terminal, desktop or interpreter call reaches it. The call runs once the sandbox is running, usually within a second. A visit to one of its shared ports also wakes it. An API client's request waits up to 30 seconds. A browser is shown a page that reloads itself. The wake is an ordinary wake. The sandbox runs while it works again. A sandbox with a time limit gets a fresh one of its own timeoutSeconds. Or it keeps the time limit it paused with, if that ends later. Billing starts from the moment it runs. With autoWake: false, the call fails with sandbox_paused until you run :wake. A wake that cannot be paid for fails with the refusal, such as insufficient_funds. The sandbox stays paused.

Names. A name is unique in the account while its sandbox can still run. That covers starting, running, paused, or stopped and persistent. A compartment is its own namespace. Its keys' names are unique among its own sandboxes. So two compartments, and the account's other keys, can each have a main. A create that names a name already held fails with name_taken (409). details.sandboxId names the holder when your key can reach it. With getOrCreate: true, the create returns the holder instead. It wakes the holder if paused, and restarts it if stopped and persistent. The response has reused: true. The other fields apply only when the create makes a sandbox. A sandbox that is deleted, or that stops without keeping its disk, gives up its name. The name stays in its own record. A delete gives up the name immediately, so a create of the name sent right after the delete succeeds. If a sandbox is deleted before its own create returns, that create returns 410 deleted with details.sandboxId. With getOrCreate: true, a holder that is deleted while the create waits for it is replaced by a new sandbox. For several callers that share one name, use getOrCreate: true. Do not delete the sandbox that a name_taken response names: another caller is starting it.

How long it runs. A sandbox runs while it works. It pauses itself when idle. There is no time limit unless you set one. Nothing needs extending. It runs on credit or, without credit, on the free usage. It pauses, or stops with onTimeout: "stop", when it is idle for idlePauseSeconds. It also does so when you stop it, and when credit, free usage, a spending limit, or maxTotalCostMicros runs out. endsAt says when the sandbox will stop or pause by itself. It is null when it never will. That happens when it has no time limit and its funding continues. endsAt gives a time when the sandbox has a time limit. It also gives a time once credit or a limit stops its funding.

During a Runtime outage. If Runtime cannot keep a sandbox's funding going (it renews the sandbox's lease while it runs), the sandbox pauses, keeping its memory and disk, and is never stopped or deleted for it, whatever onTimeout says. Once Runtime recovers, it wakes the sandbox, with what was left of its time limit. Compute is not billed while it was paused, and its paused storage only from when Runtime recovered. If the account can no longer pay for it by then, it ends as onTimeout says. While Runtime cannot pause or wake sandboxes, :pause, :wake and :restart return 503 api_unavailable and change nothing; a pause or wake Runtime accepted but did not start within 5 minutes is withdrawn, and the sandbox returns to running or paused.

A time limit. timeoutSeconds from 60 to 86,400 (24 hours) at create sets one: the sandbox pauses or stops then, as onTimeout says, busy or not. :extend moves it later, up to 24 hours ahead of now (the SDKs' keepAlive does it for you), and endsAt answers the new end. :update with timeoutSeconds sets a new limit counted from now, sooner or later than the old one, or gives one to a sandbox made without; 0 takes the limit away, and onTimeout changes what its end does. A paused sandbox counts the new limit from its next wake. A change of onTimeout that falls in the last stretch the sandbox has already paid for can end it up to about two minutes after the end asked, never before. A wake starts the limit again from the wake: the sandbox's own length, or the timeoutSeconds the wake names. An extend of a sandbox with no time limit answers at once and changes nothing, so older code that extends keeps working. A persistent sandbox has no time limit, whatever timeoutSeconds says. Without credit, a limit longer than the free usage left is cut to it, and the answer's timeoutSeconds says so: the sandbox pauses when it ends either way.

Keep it running until you stop it. persistent: true, at create or with :update, keeps it running with no idle pause. An exception is idlePauseSeconds, if you set one. This holds while the account has credit, up to maxTotalCostMicros. Its disk is billed like any sandbox's disk. A stop keeps the disk, as any stop does. :update with {"persistent": false} makes it an ordinary sandbox again.

A stop keeps the disk; only a delete removes it. A stopped sandbox reads diskKept: true. It is listed with the live sandboxes. Its disk is billed as paused storage until you delete it. :restart starts it again from that disk. Its memory is gone. It runs its image's start command again, which start reports as it does at create. A read-write volume stays attached until the sandbox is deleted. So delete the sandbox before you attach the volume elsewhere. A sandbox created with onTimeout: "delete" has its disk deleted when its time limit ends. A sandbox stopped before it ever started has no disk to keep. It reads diskKept: false, and a restart is refused. Create a new one instead. A raw HTTP client says it knows this with x-runtime-client: http/2026-10-04. A stop from a client that names an older version, or none, keeps the disk as any stop does in an account with credit. That means a card saved or a past purchase. The disk is kept at least 30 days from the stop, even if its credit runs out. In an account with none of these, the disk is kept free for three days and then deleted. To end a sandbox for good in any state, delete it.

A restart of a running sandbox reboots it. :restart stops it, keeping its disk, and starts it again from that disk, as a stop and then a restart would. Its files stay. Its processes and memory are gone, and its image's start command runs again. Use it when the sandbox's agent stops answering (sandbox_unreachable). A retry with the same Idempotency-Key returns the same restart and does not reboot it again.

Its environment. env at create sets variables. Every command, background process, terminal, SSH session, and the image's start command in the sandbox starts with them. They last for the sandbox's whole life, through pauses, wakes and restarts. A command's own env is put over them. :update with env changes them. A value sets a variable. null removes it. The rest stay. Commands started after the change get the new values. Running commands keep what they started with. A sandbox has at most 32 variables, with at most 32 KiB of names and values together. Past that, the response is env_too_large (400). A name that is not a letter or underscore followed by letters, digits and underscores is invalid_env (400). A command whose own env and the sandbox's together pass what one command can carry is refused with env_too_large (400) before it runs. Values are write-only. Responses carry only envNames. No list, log or audit entry holds a value. Values are stored encrypted. The request fingerprint that an Idempotency-Key is checked against holds a keyed hash of them, never the values. Copies made by :fork keep them, because they are on the same machine, and its processes already hold them. A sandbox created from a snapshot takes only its own env. An SSH session gets every variable whose value has no double quote, backslash or line break. A deployment that cannot store them returns env_unavailable (501) and creates nothing.

Deleting a sandbox. DELETE /v1/sandboxes/{id} works in any state. It stops the sandbox if it runs or is paused. It deletes the sandbox's disk and its paused memory. It revokes its previews and TCP ports, drops its environment, and releases its name immediately. It also removes the sandbox from every list. Compute billing ends as it does for a stop. A disk kept for :restart is billed until its host confirms the deletion, usually within seconds. Its snapshots are separate. They stay until you delete them. Its usage, charges, and audit entries stay too. A custom domain pointed at it stays yours. Point it at another sandbox, or remove it. The response is {id, status: "deleted", state, deletedAt}. Afterwards the sandbox reads nowhere, and GET returns not_found. Deleting it again returns the same response, with or without the same key. A read-only key cannot delete. A sandbox that a job or a managed service runs in is ended by that job or service instead.

Switching its image. POST /v1/sandboxes/{id}:switch-image with {"image": "web:v2"} moves a sandbox to another image. It keeps its id and name, /workspace (its home: dotfiles, pip install and npm install -g), volumes, environment, labels, previews and ports. Its processes restart, and the new image's start command runs as it does at create. Everything else on its old disk is lost, such as sudo and apt installs, and /etc. keep: "workspace", which older clients send, is accepted and changes nothing. Any other keep value is refused with switch_keeps_workspace_only (400), and nothing changes. To keep everything, snapshot the sandbox first. A running sandbox is paused first. A paused sandbox, or a stopped persistent one, is switched as it is. A stopped ordinary sandbox kept no disk, so it cannot switch (switch_needs_disk, 409). The switch is charged as a wake. The response comes once the sandbox runs the new image. If anything fails, the switch is undone. The response is switch_undone (503). The sandbox is then paused or stopped on its old image, with its files and memory as they were. The image must be ready and on the sandbox's server (image_on_another_host, 422). It must differ from the image the sandbox runs (image_unchanged, 409). It must fit the sandbox's disk (disk_too_small, 422). Copying /workspace may take up to 60 seconds.

On your tailnet. A paid sandbox can join your own Tailscale network. Store an auth key for jobs (printf %s "$TS_AUTHKEY" | runtime secrets set TS_AUTHKEY --jobs), then POST /v1/sandboxes/{id}/tailscale with {"authKeySecret": "TS_AUTHKEY"}, and optionally hostname and tags, or pass the same object as tailscale at create. The response gives its tailnet addresses, dnsName and mode; GET reads them again and DELETE logs the machine out. The key is never returned, logged or written to the sandbox's disk. Networking has the rest.

Commands and processes#

Method and path What it does
POST /v1/sandboxes/{id}:exec Run and return {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
GET /v1/sandboxes/{id}/processes/{processId}/files What a process started with traceFiles read and wrote, once it has ended

The exec body is command (run under bash -c) or argv (no shell). Optional fields are cwd, env, stdin, user, timeoutMs (default 60,000, or less when the sandbox's time limit, credit or free usage ends sooner; at most 24 hours), stream, and traceFiles (below). A timeout is a result with timedOut: true, not an error. In a sandbox that a real-value secret reaches, a command or process start may return 503 secret_withheld when its server cannot give the value right now. Nothing ran. Send it again after a few seconds; the SDKs do. env is put over the sandbox's own environment. Its values are never echoed and are stored only as hashes. user is "sandbox" by default (the user runtime, uid 1000). It can also be "root". That runs the command as root through the sandbox's passwordless sudo. The environment, PATH and working directory stay the same, and HOME=/root is set unless env sets HOME. So no command needs a sudo prefix. Starting a process takes user too. Every process reports which user it runs as in user.

Files a command read. With "traceFiles": true, the response (or the stream's exit event) adds files. It has read: every file and directory that the command, or anything it started, opened. Paths are sorted, absolute, and resolved through links. A directory ends in /. It has written: every file the command wrote. It has truncated, which is true when some paths are missing (past about 380 KB of paths). Key a cache by the contents of read. A test then reruns only when a file it really read has changed. A background process also takes traceFiles. It returns its files at …/files once it ends. Only the command's own opens are counted. Opens by other processes on the same sandbox are not. Opens through mounts that the command itself makes are not seen. Neither are lookups of files that do not exist. Each file costs 5 to 15 microseconds the first time it is opened. After that, the cost is almost nothing. A sandbox started before tracing existed returns 409 unsupported_operation, and nothing runs. Stop it and start it again. A sandbox that cannot trace the command returns 409 file_trace_unavailable, and nothing runs. The …/files route returns 409 process_running while the process still runs. It returns 409 file_trace_not_requested for a process started without traceFiles.

Streaming. With "stream": true, the response is NDJSON, with one event per line: start, stdout, stderr and exit. When a stream passes the server's time limit, it sends continue with a cursor. …/output?follow=true&cursor= resumes from that cursor. A stream that fails ends with error. That event carries the code and status the response would have had, and the cursor. After a 429 or a 5xx, follow again from that cursor, because the command keeps running. The SDKs do this for up to five minutes of failures in a row. A streamed command has no output limit.

Output limits. The JSON response holds at most 64 KiB (65,536 bytes) of each stream. This applies to a command with a timeoutMs of 60,000 or less. For a longer command, it holds up to 1 MiB of each stream. Any output past that is dropped. stdoutTruncated or stderrTruncated is then true.

While a stream is being read, its command waits for the reader. That way no output is lost, from the first byte. The sandbox keeps the latest 1 MiB of each process's output. A reader that is away for more than 10 seconds, while more than that comes out, gets {"type":"truncated","droppedBytes","resumeAt"}. That event comes before the output that remains. For large output, stream it. Or write it to a file, and read the file with GET …/files/content.

Terminals#

GET /v1/sandboxes/{id}/terminal with Upgrade: websocket and the bearer header opens a terminal: cols, rows, command (default bash -l), cwd, user=root for a root shell, 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":...}. A terminal survives a restart of the API (resuming terminals and tunnels).

A browser's WebSocket cannot send an Authorization header. So the terminal on a sandbox's page at withruntime.com uses a ticket instead: Sec-WebSocket-Protocol: runtime.terminal.v1, runtime.ticket.<ticket>. The server responds with runtime.terminal.v1. A ticket is accepted only from a page on withruntime.com. It opens one terminal in one sandbox, and it lasts 60 seconds. A refused ticket returns unauthorized (401) or forbidden (403), and says why. Your own code uses the bearer header. See the browser terminal.

Tunnels#

This is a connection from your code into one sandbox. It is not the WireGuard tunnel that joins your own network to your sandboxes (domains, TCP ports, addresses and the tunnel). GET /v1/sandboxes/{id}/tunnel with Upgrade: websocket and the bearer header opens one into the sandbox. It carries TCP connections to any port on the sandbox's loopback, and SSH logins. Several can run at the same time over one socket. It needs the key's exec permission, like a command. The CLI (runtime sandboxes ssh, runtime sandboxes port-forward) and the SDKs (sbx.tunnel(), sbx.forwardPort(), forward_port()) use it for you.

Binary frames in both directions have one kind byte, then a 32-bit big-endian stream number, then a payload. You send o to open a stream with tcp PORT or ssh KEYTYPE BASE64 (an OpenSSH public key). You send d for data, e for the end of your input, and c to close. The server sends o when a stream is opened, d for data, and e for the end of output. It sends c when a stream closes, with a reason (closed when nothing listens). It sends l for sshd's error output. It sends a on stream 0, with a 64-bit count of the data bytes the sandbox has taken. Keep at most 1 MiB of data sent beyond the last a. A client that sends more is disconnected. Text frames from the server are JSON. {"type":"ready"} comes first. {"type":"error","error":{...}} comes before the socket closes on an error. Empty and control frames also share a bounded pending queue. If that queue floods, the socket closes, even when data bytes remain below the credit limit.

Resuming terminals and tunnels after a restart#

When Runtime's API restarts, it moves each open terminal and tunnel to the API instance that serves next. The SDKs, the CLI and the console do this for you: a terminal's output and a tunnel's streams continue from the exact byte where they stopped, nothing typed is lost or sent twice, and your code sees no close. A client that speaks the WebSocket protocol itself follows these steps:

  1. The server sends {"type":"restart"}. Stop sending, keep what you would have sent, and reply with {"type":"restart"}.
  2. The server sends {"type":"resume","token":"...","replay":"..."} and closes the socket with code 1012 (Service Restart). replay, when present, is base64 input the server had received but not yet written to the sandbox.
  3. Open the same path again with ?resume=<token> and no other parameters. The server responds with {"type":"ready","processId":...,"resumed":true}. If there was a replay, send {"type":"replay","data":"<replay>"} first, then what you kept. On a terminal, send a resize if the size changed.

A client that does not reply to {"type":"restart"} within 2 seconds is closed with code 1012 and cannot resume; its terminal's shell ends as on any close. A token is signed, valid for 30 seconds, and only for the organization, sandbox and kind of socket it was issued for; the connection is authorized again like any new one. A token that was changed returns 400, and one that was already used returns 409. If the reconnect fails otherwise (for example, the server returns 503 while instances change over), retry with the same token for up to 25 seconds. Send replay back exactly as you received it. If no client resumes within the token's 30 seconds, the terminal's shell is ended as on a close (a shell you attached to with processId keeps running), and so is a tunnel's helper with the connections it carries.

Files#

Method and path What it does
GET /v1/sandboxes/{id}/files/content?path= The file's raw bytes, any size; x-content-length gives the length first (and x-content-sha256 a small file's digest), so check it: a body that ends short failed
PUT /v1/sandboxes/{id}/files/content?path= Replace the file with the raw request body, up to 1 MiB (larger ones get 413: use /uploads); makes parents; mode=755 sets its permissions (644 by default)
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}/files:chmod {"path", "mode": "755"}
POST, GET /v1/sandboxes/{id}/files/watches Watch a directory: {"path", "recursive", "events", "include", "exclude", "batchMs", "timeoutMs"}; list the watches
GET /v1/sandboxes/{id}/files/watches/{watchId}/events?cursor= Its events after a cursor (waitMs up to 8000), or follow=true for a stream
DELETE /v1/sandboxes/{id}/files/watches/{watchId} Stop a watch
POST /v1/sandboxes/{id}/uploads Begin a large upload: {"path", "size", "sha256", "mode"}; path must be under /workspace (move it with exec after the commit)
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
GET /v1/sandboxes/{id}/files/archive?path= A folder as tar, streamed; gzip=true, user=sandbox or root, and repeated relative exclude paths. A failed folder stream ends as an archive no tar reader accepts
PUT /v1/sandboxes/{id}/files/archive?path= Unpack a tar (gzipped or not) of up to 1 MiB from the raw body into the folder, making it; merges with what is there
POST /v1/sandboxes/{id}/files/archive/uploads Begin a larger one: {"path", "gzip", "user"}; gzip and user are optional; returns uploadId
PUT …/files/archive/uploads/{uploadId}?offset= The next part, up to 1 MiB, in order; a part sent twice is not written twice
POST …/files/archive/uploads/{uploadId}:commit Returns once tar has unpacked it all (:abort stops it; what was unpacked stays)
POST /v1/sandboxes/{id}/files:link A link anyone can open with no key: {"path", "method": "GET" or "PUT", "expiresInSeconds", "filename", "maxBytes", "wake", "archive"}; returns {"url", "expiresAt"} (see File links)
POST /v1/sandboxes/{id}/files:revoke-links End every link made for the sandbox so far

Paths are absolute. File errors name the path, such as file_not_found, is_a_directory, not_a_directory or permission_denied.

A watch's events are create, write, remove, rename and chmod. Repeated writes are folded together, with a count. rename includes oldPath. Events arrive in batches every batchMs (100 by default). Notices say when events were missed. overflow means more than 5,000 a second, or the kernel's own queue is full. lost means a reader more than 1 MiB behind. A stream ends with continue and the cursor after 110 seconds. It ends with paused when the sandbox pauses. Reading never wakes a sandbox. A read after it wakes carries on from the cursor with nothing lost. At most four watches run in a sandbox. Each runs for timeoutMs: one hour by default, at most a day, or 0 to run until stopped.

A watch started with "webhook": true also sends its changes to your account's webhooks as sandbox.files.changed events (see webhooks). Nothing in the sandbox or on your side has to read it. Runtime reads it without waking the sandbox, at most once every 2 seconds. An account has at most 16 such watches. A sandbox session cannot start one.

A folder moves as one tar archive. By default, tar packs or unpacks it inside the sandbox, as the sandbox user. So a folder download needs only the key's read_file permission. An upload needs write_file. user=root explicitly uses passwordless sudo inside that sandbox, with the same file permissions. An image without sudo refuses with root_unavailable before any file bytes are sent. Downloads accept repeated exclude parameters for plain relative paths, not glob patterns. .., empty, or absolute paths are refused. A multipart upload sets user when it begins. Direct uploads detect gzip from the archive header. The sandbox needs sh, tar and base64, plus gzip for a compressed archive. Every image from Runtime's base has them. At most four folders move in each direction in a sandbox at the same time. Like every file call, a folder call wakes a paused sandbox. A download whose reader takes no bytes for 60 seconds ends with 408 download_idle. Ask for it again. An upload, a chunk, or any request whose body sends no bytes for 30 seconds returns 408 request_timeout. Send it again, with a chunk at its same offset. A body that keeps arriving, however slowly, may take up to about 280 seconds to arrive. The request still has its own 110 seconds to respond. A body still arriving after 280 seconds gets 408 request_timeout too. The response says how many bytes came. Chunks sent together share the link, so over a slow link, send fewer at the same time. The SDKs and the CLI get 503 body_stalled instead of either 408. They send it again by themselves, with the same key.

The SDKs' files.upload and files.download and the CLI's runtime sandboxes cp use these routes. The SDKs also expose files.archive and files.unarchive for callers that need the archive bytes themselves.

Terminalcurl -sS "https://api.withruntime.com/v1/sandboxes/${ID}/files/archive?path=/workspace/app&gzip=true" \  -H "Authorization: Bearer ${RUNTIME_API_KEY}" -o app.tar.gzcurl -sS -X PUT "https://api.withruntime.com/v1/sandboxes/${ID}/files/archive?path=/workspace/site" \  -H "Authorization: Bearer ${RUNTIME_API_KEY}" --data-binary @site.tar.gz

A file link is an address for one file in a sandbox. It works with no API key. A person's browser or another service can use it, for example for a report, a build, a video or a dataset. POST /v1/sandboxes/{id}/files:link makes one. It returns its url, https://files.<preview domain>/<token>/<name>, and the time it ends.

  • method: "GET" (the default) downloads the file. Range requests work. A video plays, and a download resumes. ETag and If-Range keep a resume on the same version. A picture, video, sound or plain text shows in the browser. Anything else, such as a PDF, a page or a program, downloads. With filename, everything downloads under that name. Nothing a link serves runs as a page. Every response carries X-Runtime-Report, which says where to report a problem. archive: true links a folder. It downloads as .tar.gz. The file must exist when the link is made.
  • method: "PUT" makes an upload link. curl -T report.pdf "$URL" writes the body to path. It creates the folder. The write is whole or not at all. A reader sees the old file or the new one. The response is 201 with {"path", "size", "sha256"}. The body can be up to maxBytes in one request. That is 5 GiB by default, and at most that. The body streams into the sandbox as it arrives, so there is nothing to split. It must arrive within 5 minutes.
  • expiresInSeconds defaults to 3600. It can be 60 to 604800 (7 days).
  • A link works from anywhere, including another sandbox. Give one to an agent in a sandbox, and it can download or upload with curl. No key is needed.
  • A paused sandbox wakes for a link, as for any file call. wake: false never wakes it. The link returns 409 sandbox_paused instead. wake: true wakes it even when it was made with autoWake: false. That needs a key that may wake the sandbox itself, which means the create permission. Otherwise the response is 403.

A link needs the key's read_file permission to download, and write_file to upload. It acts as that key. Revoking the key ends its links. So does POST /v1/sandboxes/{id}/files:revoke-links, which ends every link of the sandbox, and deleting the sandbox. A link that has ended returns 410 (link_expired or link_revoked). A deleted sandbox returns 404 sandbox_gone. A request with the other method returns 405 wrong_method. A range past the end of the file returns 416 range_not_satisfiable. An upload the sandbox cannot write returns 422 upload_failed, and nothing is written. A sandbox that Runtime took down after a report returns 451 taken_down. Refusals are plain text, or JSON when the request accepts application/json. Each link made and each revocation is in the audit log. The bytes a link serves count as the sandbox's served traffic, as a preview's do. They have the same monthly allowance and price.

TerminalURL=$(curl -sS -X POST "https://api.withruntime.com/v1/sandboxes/${ID}/files:link" \  -H "Authorization: Bearer ${RUNTIME_API_KEY}" -H "Content-Type: application/json" \  -d '{"path": "/workspace/report.pdf", "expiresInSeconds": 86400}' | jq -r .url)curl -fLo report.pdf "$URL"  # no key

Links are for a sandbox's files. A link to a file on a volume is not available while no sandbox is running. Mount the volume in a sandbox, and link the file there.

Sessions#

A session is a short-lived token. A browser uses it to reach one sandbox without your key. A key that can run commands in the sandbox makes, lists and revokes sessions:

Method and path What it does
POST /v1/sandboxes/{id}/sessions {"ttlSeconds", "origins", "name"}; returns the session with its token, shown once, and apiUrl
GET /v1/sandboxes/{id}/sessions Its sessions still active, and those that ended in the last day
POST /v1/sandboxes/{id}/sessions/{sessionId}:revoke End one now

ttlSeconds defaults to 3,600. It can be 86,400 at most. origins lists up to 10 exact origins, https://host[:port] or http://localhost[:port].

Send the token as the bearer: Authorization: Bearer rtsess_.... The session can use the sandbox's routes under :exec, /processes, /files, /uploads, /interpreter, GET /v1/sandboxes/{id}, and GET /v1/sandboxes/{id}/previews. Those routes work for its own sandbox only. Everything else returns forbidden (403). An ended session returns unauthorized (401). A preview token that the session is handed ends when the session does.

The API sends CORS responses only for a session's own origins. That covers a preflight from a page that a session of that sandbox lists. It also covers the responses to that session's requests, errors included. A session request from any other page is refused. Requests with a key carry no CORS headers.

A replay of a create with the same Idempotency-Key returns the session with token: null. Revoke it and make another. The SDKs do that for you.

Private preview tokens#

POST /v1/sandboxes/{id}/previews accepts optional ttlSeconds and expiresAt fields. GET /v1/sandboxes/{id}/previews/{port} accepts them as query parameters. runtime_sandboxes_previews_create accepts the same fields. expiresAt is an ISO timestamp with a timezone. It must be at least a minute and at most a week ahead. Invalid deadlines return invalid_request (400) before the preview changes.

With expiresAt alone, the token ends by that deadline. With both fields, it ends by the earlier of the requested lifetime and the deadline. Existing relative lifetimes keep their rounding. An absolute deadline rounds down to whole seconds and is never exceeded. Read tokenExpiresAt from the response. Do not calculate it yourself. A connection the token opened, such as a WebSocket or a long download, ends when the token expires. Open it again with a fresh token. A preview token read through a sandbox session also ends when that session expires. With less than a minute left, the session receives no fresh token. Sessions still cannot create or rotate previews, or use MCP.

The token and its expiry are not stored on the preview. Every create and every read signs its own token for that response. A list, or a read without ttlSeconds or expiresAt, signs a token that lasts a day. So a create that asked for a week, and a list just after it, show the same preview id. They return different tokens and different tokenExpiresAt values. Both tokens work, each until its own tokenExpiresAt. A later read never shortens or extends a token you already hold. Only :rotate, or removal of a key, ends it early.

Other products#

Method and path Product
POST /v1/sandboxes/{id}/interpreter:run, …/interpreter/contexts (language: python, javascript, typescript, r, java, bash, go), …/contexts/{context}:restart, :interrupt, DELETE, GET …/contexts/{context}/results/{file} Code interpreter
POST, GET /v1/sandboxes/{id}/mounts, …/mounts:unmount Bucket mounts
POST, GET, DELETE /v1/sandboxes/{id}/tailscale Your Tailscale network
POST, GET /v1/sandboxes/{id}/previews, …/previews/{port}, …/previews/{port}:rotate Previews
POST /v1/sandboxes/{id}/browser:start, GET /v1/sandboxes/{id}/browser, POST /v1/sandboxes/{id}/browser:stop A Chromium browser and its private CDP address (JavaScript)
POST /v1/sandboxes/{id}/desktop:start, :stop, :act, GET …/desktop/screenshot Desktop
POST, GET /v1/sandboxes/{id}/desktop/recordings, GET …/recordings/{recordingId}, …/video, :stop, DELETE Desktop recordings
GET /v1/mcp/catalog; POST, GET, DELETE /v1/sandboxes/{id}/mcp MCP servers
GET, PUT /v1/sandboxes/{id}/network Network rules
GET /v1/egress-secrets, GET, PUT, DELETE /v1/egress-secrets/{name}, PUT /v1/egress-secrets/{name}/sandboxes and /rules Secrets
GET, PUT, DELETE /v1/networks/upstream-proxy Your own proxy
GET, PUT /v1/networks/private Your sandboxes reach each other by name
GET, PUT /v1/networks/signed-requests Your sandboxes sign requests as Runtime's agents
POST, GET /v1/feedback Feedback
POST /v1/support/messages, GET /v1/support/conversations/{id} Support
GET /v1/me, GET /v1/usage, GET /v1/usage/requests?range=24h, GET /v1/limits, GET /v1/audit, GET /v1/account/emails, POST /v1/account/emails:update Account
GET /v1/sso Single sign-on
GET, PUT, DELETE /v1/billing/auto-top-up Auto top-up
GET, POST /v1/identity/token?audience= (from inside a sandbox, with its request token) Identity tokens
GET /v1/usage/compare?provider=e2b&days=30, GET /v1/switching, POST /v1/switching Switching
POST, GET /v1/images, GET /v1/images/{id}, …/logs?follow=true, :tag, :untag, :delete, GET /v1/images/resolve?ref= Custom images
POST /v1/images/context/missing, PUT /v1/images/context/{digest} Image build contexts
GET, POST /v1/images/registries, POST /v1/images/registries:delete Private registries
POST, GET /v1/volumes, GET /v1/volumes/{id}, :delete, :backup, :backup-policy; GET /v1/volume-backups, …/{id}, :delete Volumes and backups
POST /v1/sandboxes/{id}:fork, POST /v1/sandboxes/{id}:snapshot, GET /v1/snapshots, …/{id}, :update (name, labels), :extend, :delete Forks and snapshots
GET /v1/sandboxes/{id}/metrics?range=1h, GET /v1/events Metrics and events
POST, GET /v1/webhooks, GET /v1/webhooks/{id}, :update, :rotate-secret, :test, :delete, …/deliveries, POST /v1/webhook-deliveries/{id}:retry Webhooks
POST, GET /v1/otel-exports, GET /v1/otel-exports/{id}, :update, :flush, :delete OpenTelemetry export

Secrets. PUT /v1/egress-secrets/{name} takes value and one of two kinds. With hosts, the value is added on the way out: the machine sees a placeholder, and the proxy puts the value into HTTPS requests to those hosts. With "exposed": true and no hosts, the machines get the value itself, up to 64 KiB, several lines included. A real value takes no header, format, rules, githubApp, git or api. It needs only naming sandboxes (only.sandboxes) or Apps (only.apps) by id, with no labels: code in the machine can read it. A replacement may leave only out to keep its scope. PUT /v1/egress-secrets/{name}/sandboxes is refused for it, so its machines change only when it is stored again with its value. Writing a real value takes exec on each machine it reaches, the ones it keeps too: otherwise 403 forbidden, with the machine in details.id. env works with both, but a real value's variable (its env, or its name) is never one that makes programs load or run code, such as BASH_ENV, NODE_OPTIONS, GIT_SSH_COMMAND, PYTHON* or LD_*: that is 400 invalid_request with field env. expectedVersion writes only if the secret is at that version; 0 means it must not exist yet. Otherwise nothing is written, and the answer is 409 version_conflict with the current version in details.version. GET /v1/egress-secrets/{name} returns one secret as the list shows it, with exposed and version, never its value. A secret that does not exist is 404 not_found. See a secret the machine needs itself.

Snapshots accept mode: "memory" (the default) or mode: "disk" in POST /v1/sandboxes/{id}:snapshot. Memory snapshots keep files and running processes. Disk snapshots keep only the root filesystem. A sandbox created from one boots fresh, with no saved processes. It uses the vcpu, memoryMiB and diskMiB that the create asks for. Each one left out is taken from the snapshot. Its disk is at least the saved size. A copy of a memory snapshot continues the saved machine, so it has the snapshot's size. Naming a different size returns 400 invalid_request. Neither mode takes a source with attached volumes. Both keep the same ownership, retention, and storage pricing. A running source is paused for the capture, and woken after it. A disk snapshot's pause first waits for the sandbox to write to its disk what it still holds in memory. That takes milliseconds when the sandbox is quiet, and up to a few seconds while it writes. The returned snapshot includes its mode. Keep the source paused until the snapshot's state is ready or failed. Prefer: wait can return while capture is still in progress.

Where deferred compression has been qualified and enabled, a memory snapshot may be ready with compressionPending: true. It can start on the same server from its captured raw state. meteredBytes is zero until Runtime publishes the verified compressed allocation. Temporary raw files are not charged. While compression is pending, backedUp is false and durability.state is none. Its off-server copy is queued after compression. See snapshot storage.

Evictable sandboxes

Set evictable: true on a create, or with :update, for work you can recreate, such as build caches, CI machines and test fixtures. An evictable sandbox that has been paused or stopped for at least 15 minutes may be deleted when its server needs the disk. The sandbox idle longest is deleted first. The deletion is the same as DELETE: its disk and paused memory are deleted, and it no longer appears in lists. A running sandbox is never deleted for it, and evictable: false turns it off. Setting it to true, on a create or an update, needs a key that may delete the sandbox; otherwise the request returns 403. The sandbox's evictable field reads the setting back.

TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime();const cache = await runtime.sandboxes.create({ evictable: true, labels: { role: "build-cache" } });

Keeping copies on several servers

A copy of a snapshot starts on a server that holds the snapshot. That is the server it was taken on, or one that Runtime keeps a checked copy on. When none of those has room, Runtime copies the snapshot to another server in the region. The sandbox starts there once the copy is checked, usually within half a minute. This works as soon as the snapshot is ready, even while its compression is pending. A server starts at most 4 copies a minute of one account's snapshot while that account's sandboxes there are busy on CPU. Past that, the start moves to another server that holds the snapshot and has room. It is refused with account_busy_here only when no such server exists. To be sure copies start on several servers, keep several warm sources on different servers.

spreadFrom lists 1 to 16 ids of your own sandboxes or snapshots. The new sandbox starts on a server that none of them is on. It stays apart if its start is moved. With no other server that has room, the create returns 503 no_capacity. Its message names spreadFrom. Nothing is made or charged. An id that is not a sandbox or snapshot this key may read returns 400 invalid_request. A copy of a snapshot whose spreadFrom names every server holding that snapshot starts on another server. The snapshot is copied there first, as when those servers are full. Compare placement tags to see which of yours share a server.

Three warm sources on different servers. Then copies, made round-robin:

TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime();const a = await runtime.sandboxes.create();const b = await runtime.sandboxes.create({ spreadFrom: [a.id] });const c = await runtime.sandboxes.create({ spreadFrom: [a.id, b.id] });const snapshots = [];for (const source of [a, b, c]) snapshots.push(await source.snapshot());for (let i = 0; i < 12; i++) {  await runtime.sandboxes.create({ snapshot: snapshots[i % 3]!.id });}
Pythonfrom withruntime import Runtimeruntime = Runtime()a = runtime.sandboxes.create()b = runtime.sandboxes.create(spread_from=[a.id])c = runtime.sandboxes.create(spread_from=[a.id, b.id])snapshots = [source.snapshot() for source in (a, b, c)]for i in range(12):    runtime.sandboxes.create(snapshot=snapshots[i % 3]["id"])

A wake runs on its sandbox's server. A copy starts on a server that holds its snapshot while one has room. When none has room, it starts on another server once the snapshot is copied there. That adds seconds to its start. A fork's copies do the same.

A copy of a snapshot that saved memory, and a wake, load all of that memory within seconds. So a server counts each one at its full memory size from the start. Many copies or wakes at the same time on one server use up what memory it has. The ones past that wait while it makes room. If none gets room in time, they return 503 host_memory_pressure with Retry-After. Send each one again after that wait, with the same Idempotency-Key. Back off if it is refused again, as the SDKs do.

Forks:

  • A fork's copies keep the source's size and CPU (cpu and cpuFloorMillis). They are quoted as a create with those values would be.
  • Copies keep the source's labels unless the fork names others. They get no name unless the fork gives one (name, then name-1, name-2 for several). Names are unique in the account, or in a compartment. A snapshot taken without labels keeps its sandbox's labels. A sandbox created from a snapshot keeps the snapshot's labels unless the create names others. The one exception is share: made-keys. Copies, sandboxes and snapshots never inherit it. Set it on each snapshot you mean to share (see Run a swarm). A share label with any other value is yours, and is copied as before.
  • The snapshot a fork takes is deleted when the fork ends, whether every copy started or not. It is never billed, unless the request says keepSnapshot: true.
  • A fork that fails after its snapshot returns the failing step's error. details.startedSandboxIds names the copies that did start. They are deleted. When the error is a 5xx, a failure on Runtime's side, none of their time is billed, and details.copiesBilled is false. When it is a 4xx, caused by the request or the account (a limit, a quota, or a copy or the source deleted or changed during the fork), the time they ran is billed. A copy the fork could not delete at once is named outside details.deletedSandboxIds, details.copiesDeleted is false, and it is deleted within 15 minutes. If the server handling a fork stops before it answers, the fork is marked failed with a 500 once it has run longer than any fork can (15 minutes, plus about 20 seconds for each GiB of the source's memory), and its copies are deleted unbilled within 15 minutes after that. A fork that does not answer within the request deadline is a failure on Runtime's side, unbilled; if your client closes the connection before the fork answers, the copies are deleted and billed for the time they ran. A retry with the same Idempotency-Key returns that same error.
  • If a fork stops partway and left its source paused, the source stays paused. GET /v1/notices holds a fork-left-paused notice. It names the sandbox, the fork's key, and how to wake it.

Metrics, events, webhooks and OpenTelemetry export are in metrics and webhooks. That page covers what each field means, how deliveries are signed and retried, and what an export sends.

The OpenAPI document lists every field of every route.

Account#

GET /v1/me says who a key is. It returns orgId, principalId, credentialId, the account's orgName, and the role of the member who made the key (owner, admin, developer or billing). That role limits what the key may do. GET /v1/usage shows what the account has and has used. Money is an integer count of microdollars, given as strings (1,000,000 is one dollar). The values are 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
expired Credit that expired, or grant credit taken back
available What can still be spent, after what running sandboxes are about to use
takenBack The part of spent that refunds and disputes took
trial Machine time without credit: {totalMs, usedMs, reservedMs, availableMs, offer, renewsAt}, or null
freeUsageHold Why the account has no free machine time, {reason, message}, or null
allowances This month's free usage, one row per pool, below
outbound This month's outbound traffic, below
kinds Every resource, however many, a row a kind: {kind, resources, chargedMicros, heldMicros}
resources The newest hundred resources, with what each used and was charged

Each resource has resourceId, name, kind, state, createdAt, vcpu and memoryMiB (null for a kind with no size), runningSeconds (the billed running time), activeCpuSeconds, memoryGiBSeconds, rates and chargedMicros. chargedMicros covers every meter, including outbound traffic. Divide it by runningSeconds to get the cost of one second of a run.

outbound is the account's outbound traffic for this calendar month, in UTC. It has month (2026-09), sentBytes, freeBytes (what the allowance covered), billableBytes, and allowanceBytes (100 GiB). It also has chargedMicros and writtenOffMicros, as strings. writtenOffMicros is traffic that your balance or a spending limit could not cover. It is never charged later (pricing).

Export settled usage

GET /v1/usage/export?since=2026-09-01T00:00:00Z&until=2026-10-01T00:00:00Z reads settled usage for every product the key can see. since is inclusive and until is exclusive. Both are UTC ISO timestamps ending in Z. The filter uses when an interval settled, not when its resource was created.

The response is {since, until, data, nextCursor, finalThrough, csv}. Each row names the interval and resource, the kind, the meter, the funding, the interval start and end, the settlement time, the billed milliseconds, chargedMicros, the quoted component rates, and the measurement evidence. Money and quantities are exact decimal strings. billedMilliseconds, measurements and evidence can be null when unavailable.

Each interval is one row, written once when it settles. A later read never changes or repeats it. Before the first page, a read settles your running sandboxes through about a minute ago. So their use appears in the export, not only what ran up to their last hourly window. A read settles up to 100 sandboxes, the longest unsettled first. Only sandboxes paid from credit are settled. A trial's sandboxes are not. Storage is paid an hour at a time. That covers a paused sandbox's memory and disk, a stopped sandbox's disk, images, snapshots, volumes, and volume backups. Each hour appears in the export within a minute of its end, usually within seconds. Pending holds are not in the export. These are the credit set aside for work still running (heldMicros in GET /v1/usage). A hold becomes a row when it settles.

finalThrough is a UTC timestamp before which nothing can still change. No row with a settledAt earlier than it can still appear, and rows never change once written. A range whose until is at or before finalThrough is complete for good. Usage that settles later is stamped at or after it. It is null only when the marker could not be taken in time. In that case, read again. Each row's final (and the CSV's last column) is true once its resource is deleted. At that point nothing of it is still running or open, and every charge it will ever have is settled before finalThrough. A volume's backups are resources of their own. They bill until their retention ends. So they are not final when the volume is.

Follow nextCursor with the same range until it is null. Each page rechecks the key's usage permission and resource visibility. These are live reads. Usage that settles after an earlier page can appear on a later page. The server's csv includes a header on its first page only. So concatenate the pages unchanged, in order. This range export covers more than the newest hundred resources in GET /v1/usage.

GET /v1/limits shows what this key may do, and what its agent may still spend:

JSON{  "access": "full",  "daily": {    "limitMicros": "25000000",    "usedMicros": "3100000",    "remainingMicros": "21900000",    "window": "24h"  },  "trial": null,  "credit": {    "availableMicros": "42500000",    "dailyPaceMicros": "6100000",    "runwayDays": 6,    "lowBelowDays": 5,    "state": "ok",    "addedBy": "owner"  }}
  • 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 what is charged and what its running sandboxes are about to use.

  • Past the limit, a create, wake, extension or renewal fails with 402 spending_limit_reached and charges nothing.

  • trial is the machine time the account can run without credit, in milliseconds. It has the same figures as GET /v1/usage. It is null when the account has none, as here. availableMs is what a new sandbox without credit can still use. offer is monthly for the free usage. It is hours for an account given free hours before 5 October 2026, which keeps what is left of them. renewsAt is when the next month starts. An account that has never paid has its free machine time once, until freeUntil on its allowances rows. The field keeps its older name, so code that reads it keeps working.

  • credit is the account's credit. availableMicros is what is left now, as available in GET /v1/usage. dailyPaceMicros is a day's spend at this month's pace. Last month's pace is used while this month is under three days old. It is null when nothing is spent. runwayDays is the whole days the credit lasts at that pace, as the console's Home shows it. state is low when credit falls under lowBelowDays days. It is empty at none. Running paid sandboxes then pause, and new ones are refused. The credit.low and credit.empty webhooks are sent at the same moments. addedBy is owner when a person adds credit on the website. It is agent when the account was opened with a wallet and no person pays for it. Then its own agent adds credit with npx withruntime billing topup <usd> before runwayDays reaches 0.

A limit is set, changed or removed only on the website, at API keys. That is done by the member who made the key, or by an owner or admin. A key can make only keys with a daily limit that fits within its own. It cannot change its own limit.

GET /v1/audit is the account's audit log, newest first. It records member and role changes, keys, connections, limits, credit, network rules, secrets and deletions. Each entry has actor, at, ip, requestId, via and sandbox. sandbox is the sandbox the call came from, when code in it called http://runtime.internal. Otherwise it is null. Filter with action (key.created, or a group such as member.). Page with before=<next>, and set limit (1 to 200). It needs a key with full access, or a read-only key, made by an owner or admin. See teams.

GET /v1/sso shows the account's single sign-on. It lists each identity provider, its domain, and whether DNS has proven it. It shows the role people join with, whether single sign-on is required, and SCIM directory sync. It is read only, with the same keys as the audit log. An owner changes it on the website. See single sign-on.

GET /v1/identity/token?audience=sts.amazonaws.com is for code inside a sandbox. It takes the sandbox's RUNTIME_ID_TOKEN_REQUEST_TOKEN as the bearer, not an API key. It returns {"token", "expiresAt", "issuer", "subject", "audience"}. That is an OIDC token that names the sandbox. It lasts 10 minutes by default (lifetimeSeconds 60 to 3600). See identity tokens.

Domains, TCP ports, addresses and the tunnel#

These are for paid accounts only. See networking. Each one is its own product, not a sandbox's:

Route What it does
POST /v1/domains {hostname, sandboxId, port}: claim a hostname; returns its DNS records
POST /v1/domains/{hostname}:verify Check the TXT record; active when it matches
GET, DELETE /v1/domains/{hostname} One domain; stop serving it
POST /v1/ports {sandboxId, port}: a public TCP port; returns connect
GET /v1/ports, DELETE /v1/ports/{id} List (?sandboxId=); close
POST /v1/ips {family}: reserve a dedicated outbound address (4 or 6)
GET /v1/ips, DELETE /v1/ips/{id} List; release
POST, GET, DELETE /v1/networks/wireguard Create ({subnet}), read or delete the WireGuard tunnel
POST /v1/networks/wireguard/peers {name, publicKey?, routes?}: add a peer; returns its wg-quick config
POST /v1/networks/wireguard/peers/{id}:rotate {publicKey?}: a new key for the peer
DELETE /v1/networks/wireguard/peers/{id} Remove a peer

A dedicated address needs credit on the account. The rest need a kept top-up. Without one, a request gets payment_required (402).

Pages#

A list that can grow returns {"data": [...], "nextCursor": "..."}. Pass nextCursor back as cursor. A null value ends the list. limit is 1 to 100, and up to 500 for GET /v1/feedback.

Short lists return {"data": [...]} in full, with no cursor. Examples are a sandbox's file watches, mounts, interpreter contexts, desktop recordings, registry credentials and the MCP catalogue. A few lists have their own shape:

  • The audit log, GET /v1/audit, returns {"events": [...], "next": "..."}, newest first. Pass next back as before. A null value ends the log. limit is 1 to 200.
  • Secrets, GET /v1/egress-secrets, returns {"secrets": [...], "next": "..."}, sorted by name. Pass next back as after. A null value ends the list. limit is 1 to 100, and 50 by default.
  • Single sign-on, GET /v1/sso, returns connections, scim and manage.
  • Process output and file-watch events carry their own nextCursor beside the chunks or events. Build logs return lines with nextAfter.

Versions#

This is 0.2.0. The OpenAPI document's info.version is the version you are talking to.

The 0.1.0 routes under /v1/resources were removed on 22 September 2026. Each one returns 410 upgrade_required. details.replacement names the route that replaced it. The migration guide maps each old call to its new one.

Each product has one plural name in every path, SDK call, command and MCP tool: /v1/sandboxes, runtime.sandboxes, runtime sandboxes, runtime_sandboxes. When a name changes, the old one keeps working for 30 days. Each response to it carries a Warning header with the new name, and a Link to the new path. The SDKs and the CLI print the warning once. An MCP response carries it as warning. Once the day it stops is set, responses also carry Deprecation and Sunset (that day), and the warning names it. From that day, the old name returns 410 name_retired. Its hint names the new one.

Try it on your own work. Sign up, then paste the setup prompt into your coding agent.

Start free, no card

Was this page right?