# 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**. ## 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](#sessions) instead. ```bash no-run curl 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](https://withruntime.com/account/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](./security#compartments) 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](./teams#run-a-swarm)). ```bash no-run curl -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//{id}`. A change that is not a plain update uses `POST /v1//{id}:`, 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 , or pause ), 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](https://withruntime.com/account/billing#add-credit). Credit you add within 30 days of signing up is matched, up to $1,000 in total ([welcome match](./pricing#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](./pricing)). For that account, the `hint` says to add credit yourself. Use `npx withruntime billing topup ` 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 --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`, `browser_not_running`, `call_dismissed`, `call_started`, `cell_not_run`, `checksum_mismatch`, `chunk_conflict`, `compartment_not_empty`, `conflict`, `desktop_failed`, `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`, `in_use`, `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`, `notebook_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`, `program_not_found`, `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_starting`, `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 | no | `cell_timed_out`, `page_timed_out` | It ran past its time and was stopped, so it may have run in part: check what it did before you send it again | | 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`, `rate_limited` and `sandbox_starting` mean nothing ran. `sandbox_starting` (`503`) means the sandbox exists but is waiting for a server with room; a call sent right after a create can return it. 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](./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](./limits#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: ` 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](./sandbox-environment#timers-wake-an-idle-sandbox)). 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](./storage#attaching-a-volume)). 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: it starts on a server none of them is running on (see "Keeping copies on several servers" below). An id this key may not read returns `400 invalid_request` | | `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](./security#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](./security#compartments) 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](./pricing#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](./networking#your-tailscale-network) 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](./security#a-secret-the-machine-needs-itself) 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 size.** The JSON response returns up to 256 MiB of each stream, and a streamed exec (`"stream": true`) returns all of the output with no limit. `stdoutBytes` and `stderrBytes` give the number of bytes each stream wrote. A stream past 256 MiB is cut there and its truncation flag is `true`; it can also be cut sooner, and marked the same way, when the API host is short of disk space. To limit the response, set `maxOutputBytes`: the response then holds at most that many bytes of each stream, cut between UTF-8 characters, and `stdoutTruncated`, `stderrTruncated` and `truncated` are `true` for a stream that was cut. `stdoutBytes` and `stderrBytes` still count every byte. An older sandbox returns at most 64 KiB of each stream for a command with a `timeoutMs` of 60,000 or less; for a stream it cut, the truncation flag is `true` and its byte count is `null`. Stop and start the sandbox to remove the limit. 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](#resuming-terminals-and-tunnels-after-a-restart)). 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.`. 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](./security#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](#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=` and no other parameters. The server responds with `{"type":"ready","processId":...,"resumed":true}`. If there was a `replay`, send `{"type":"replay","data":""}` 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](#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](./observability#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. ```bash no-run curl -sS "https://api.withruntime.com/v1/sandboxes/${ID}/files/archive?path=/workspace/app&gzip=true" \ -H "Authorization: Bearer ${RUNTIME_API_KEY}" -o app.tar.gz curl -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 ``` ### File links 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.//`, 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. ```bash no-run URL=$(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](./javascript#a-browser-over-cdp)) | | `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`, `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](./pricing#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](./security#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](./storage#snapshots-survive-their-server). ### 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. ```ts check import { 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. The new sandbox starts on a server that none of them is running on. It stays apart from them each time it is placed again. With no other server that has room, the create returns `503 capacity_unavailable` immediately. Its message names `spreadFrom`. Nothing is made or charged. An id that is not a sandbox this key may read returns `400 invalid_request`. Snapshot ids are not accepted in `spreadFrom`, and a copy of a snapshot cannot take `spreadFrom` yet: that create returns `501 feature_unavailable`. Compare `placement` tags to see which of yours share a server. Three warm sources on different servers. Then copies, made round-robin: ```ts check import { 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 }); } ``` ```python check from withruntime import Runtime runtime = 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](./teams#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](./observability). 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`, the `role` of the member who made the key (`owner`, `admin`, `developer` or `billing`), and the key's `reach`: `agent` when it acts only on what its own agent made, `compartment` when it acts on what every key of its compartment made, or `account` when it acts on everything in the account. 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](./pricing#network-products)). ### 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](./observability) 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 ` before `runwayDays` reaches 0. A limit is set, changed or removed only on the website, at [API keys](https://withruntime.com/account/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=`, 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](./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](./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](./identity-tokens). ## Domains, TCP ports, addresses and the tunnel These are for paid accounts only. See [networking](./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 and registry credentials. 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](./migrate) 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. --- # Runtime vs AWS Lambda MicroVMs Runtime runs agent code in Firecracker microVMs, like Lambda MicroVMs. For an agent that mostly waits on a model, it costs **85% less**. **The saving:** 1,000 one-minute runs of a 2 vCPU, 4 GiB sandbox cost **$0.64 on Runtime and $4.20 on Lambda MicroVMs**. At 100,000 runs a month, that is $63.89 against $420.33, **$356 a month saved**. Runtime needs no AWS account, IAM role or VPC set up first. Comparing more than two providers? [AWS Lambda MicroVMs alternatives](/compare/aws-lambda-microvms-alternatives) ranks the other providers by the cost of the same job. ## Where Runtime is better - **You pay for the CPU you use.** Lambda MicroVMs bill the baseline vCPUs and memory for every second a MicroVM runs, busy or not. Runtime measures the CPU your code actually uses. Time spent waiting on a model costs only a small floor, a twentieth of a vCPU. - **Idle time bills only storage.** A Runtime sandbox pauses itself after 300 seconds with nothing happening in it. It keeps its memory and processes. While paused, it pays $0.08 per GB (10⁹ bytes) of saved state a month. - **An uptime promise that pays itself.** Paid accounts get 99% API uptime each month. A month below that returns 10% of that month's charges as credit. No claim is needed ([Uptime Promise](/legal/sla)). - **Lower rates on both meters.** $0.025 per vCPU-hour, against about $0.0997, and $0.0075 per GiB-hour of memory, against about $0.0132 per GB-hour. With both CPUs busy the whole time, the example job still costs $1.33 on Runtime and $4.20 on Lambda MicroVMs. - **Bigger sandboxes.** A paid Runtime sandbox takes up to 16 vCPUs and 64 GiB. Memory is chosen apart from CPU. A MicroVM's baseline tops out at 4 vCPUs and 8 GB, always 2 GB per vCPU. - **Sessions that last.** A MicroVM keeps its state for up to 8 hours. A Runtime sandbox has no time limit. It runs while it works, or stays up idle with `persistent: true`. A paused one keeps its memory while you have credit. - **Fork a running machine.** A Runtime fork copies a sandbox as it is now, memory and processes included, into up to 100 running copies. - **Keys the sandbox never sees.** Store an API key once. The sandbox holds a placeholder. Runtime's proxy adds the value only to HTTPS requests to the hosts you name, so a prompt injection has nothing to leak ([security](./security)). - **Your agent sets itself up.** It runs `npx withruntime sandboxes run -- ...`, shows you a link, and starts once you approve in the browser. No access key goes into a prompt or a config file. The [MCP server](./mcp) reuses the same connection. - **Guardrails for agents.** Read-only keys, a daily spending limit per key and `maxTotalCostMicros` on each sandbox keep an agent inside its budget. Every write takes an idempotency key. **Also included:** a code interpreter, network allow and deny lists, custom images, volumes backed up daily, private previews, a Linux desktop, metrics, webhooks, OpenTelemetry export, S3, R2 and GCS bucket mounts, identity tokens for AWS and Google Cloud, and free single sign-on. Paid accounts add custom domains, TCP ports, dedicated outbound addresses and WireGuard private networks. See [products](./products). ## At a glance Lambda MicroVMs' figures come from AWS's public pricing and product pages. Last checked 25 October 2026. Rates are for Arm (Graviton) in US East (N. Virginia). | | Runtime | AWS Lambda MicroVMs | | -------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | Isolation | Firecracker microVM, own kernel | Firecracker microVM | | CPU billing | $0.025 per vCPU-hour of measured CPU, with a small floor | $0.0000276944 per baseline vCPU-second while running | | Memory billing | $0.0075 per reserved GiB-hour | $0.0000036667 per baseline GB-second while running | | Sizes | Up to 16 vCPUs and 64 GiB paid, chosen apart | Baseline up to 4 vCPUs and 8 GB, 2 GB per vCPU | | Session length | No time limit: runs while it works, or `persistent` | State kept up to 8 hours | | Suspend | Files, memory and processes, kept while you have credit | Memory and disk in a snapshot, at $0.08 per GB (10⁹ bytes) a month | | Free start | 100 free hours of a 1 GB machine in the first 30 days, no card | No free tier for MicroVMs | | Plan fee | None; prepaid credit from $10 | None; an AWS account | | Agent sign-in | Browser approval; no key in the agent's config | AWS credentials and IAM | ## Cost for the same job Take 1,000 runs of a 2 vCPU, 4 GiB sandbox (a 2 vCPU, 4 GB baseline on Lambda MicroVMs). Each run lasts 60 seconds and keeps the CPU busy for 20 CPU-seconds. This is an agent that spends most of its time waiting for a model. ``` Runtime CPU 1,000 × 20 s / 3,600 × $0.025 = $0.14 Memory 1,000 × 60 s / 3,600 × 4 × $0.0075 = $0.50 Total $0.64 Lambda CPU 1,000 × 60 s × 2 × $0.0000276944 = $3.32 Memory 1,000 × 60 s × 4 × $0.0000036667 = $0.88 Total $4.20 ``` - **Saving:** 85%, or $3.56 per 1,000 runs. - **Per month:** at 100,000 runs, $63.89 on Runtime against $420.33 on Lambda MicroVMs. - **Busier work:** with both CPUs busy for the whole minute, $1.33 on Runtime against $4.20 on Lambda. At this size, Runtime is cheaper however busy the sandbox is. Snapshot storage and its reads and writes, data transfer, taxes and credits are excluded from both. On Runtime, inbound traffic is free. Each account's first 100 GiB out a month is free, then $0.02 per GB (10⁹ bytes). See [pricing](./pricing) for Runtime's terms. ## How to switch Give your coding agent the one instruction in [migration](./migrate). It replaces the MicroVM calls on a branch, tests them on the free usage, and tells you what you save each month. Your old code stays on the main branch until you merge. A MicroVM image built from a container image maps to a [custom image](./images) built from the same Dockerfile. A MicroVM's HTTPS endpoint maps to a [preview](./javascript#share-a-port): ```ts check import { Sandbox } from "withruntime"; const box = await Sandbox.create(); try { await box.spawn("python3 -m http.server 8080"); const preview = await box.previews.create(8080); console.log(preview.urlWithToken); } finally { await box.stop(); } ``` ## When Lambda MicroVMs may fit better - **Everything else is on AWS.** A MicroVM reaches your VPC through the Lambda Network Connector. It is governed by the IAM policies you already run. - **Many regions.** AWS runs Lambda in regions worldwide. Runtime runs in one US region. - **Arm code.** MicroVMs run on Graviton. Runtime sandboxes are x86-64. ## Sources Checked 26 October 2026. - [AWS Lambda pricing](https://aws.amazon.com/lambda/pricing/) - [Lambda MicroVMs](https://aws.amazon.com/lambda/lambda-microvms/) - [Announcing Lambda MicroVMs](https://aws.amazon.com/blogs/compute/announcing-lambda-microvms-serverless-compute-environments-with-vm-level-isolation-and-near-instant-startup/) - Runtime [pricing](./pricing), [security](./security) and [products](./products) --- # Runtime vs Blaxel Runtime bills the CPU your agent uses. It costs **81% less than Blaxel** while running an agent that mostly waits on a model. **The saving:** 1,000 one-minute runs of a 2 vCPU, 4 GiB sandbox cost **$0.64 on Runtime and $3.45 on Blaxel**. At 100,000 runs a month that is $63.89 against $345.00, **$281 a month saved**. Your Blaxel sandbox code keeps working. Change one import. Comparing more than two? [Blaxel alternatives](/compare/blaxel-alternatives) ranks the other providers by the cost of the same job. ## Where Runtime is better - **Lower cost while running.** Blaxel bills $0.0000115 per GB of memory per second of active time, $0.0414 per GB-hour. CPU comes with the memory. Runtime charges $0.0075 per GiB-hour of memory and measured CPU at $0.025 per vCPU-hour. - **A written uptime promise.** 99% API uptime a month for paid accounts, measured from outside. 10% of a short month's charges comes back as credit automatically ([Uptime Promise](/legal/sla)). - **Cheaper even when busy.** With both CPUs working the whole time, the example job costs $1.33 on Runtime and $3.45 on Blaxel. - **CPU and memory chosen apart.** Blaxel sets a sandbox's CPU from its memory: 8 GB gets 4 cores. On Runtime you choose vCPUs and memory separately. A CPU-heavy job does not pay for memory it does not need. - **Idle time costs no compute here either.** A Runtime sandbox pauses itself after 300 seconds with nothing happening in it, or after as few as 10 if you set it. A paused sandbox pays only paused storage. A command still running, an open connection, network traffic or CPU use keeps it awake. - **Cheaper to keep paused work.** A paused Runtime sandbox keeps its files, memory and processes for $0.08 per GB (10⁹ bytes) a month, while you have credit. Blaxel's standby snapshots cost $0.20 per GB a month. - **A one-line switch.** `withruntime/blaxel` runs code written for Blaxel's sandbox SDK, in JavaScript and Python: processes, files, previews, snapshots, forks and the code interpreter, with no Blaxel key. Credit you add within 30 days of signing up is matched, up to $1,000 in total. - **Single sign-on at no extra charge.** SAML or OIDC sign-in, SCIM, roles and an audit log come with every Runtime account ([single sign-on](./single-sign-on)). Blaxel lists SAML among the enterprise features of its Dedicated Support add-on. - **Private networks without a custom plan.** A paid Runtime account joins its own network to its sandboxes over WireGuard, for $5 a month ([private networks](./networking#private-networks)). Blaxel lists private network connectivity on its Custom plan. - **Your agent sets itself up.** It runs `npx withruntime sandboxes run -- ...`, shows you a link, and starts once you approve in the browser. No API key goes into a prompt or a config file. The [MCP server](./mcp) reuses the same connection. - **Guardrails for agents.** Give an agent a read-only key or a daily spending limit per key. Cap any sandbox's whole cost with `maxTotalCostMicros`. Every write takes an idempotency key, so a lost response never creates a second sandbox. **Also included:** a code interpreter, network allow and deny lists, secrets the sandbox never sees, custom images, volumes backed up daily, snapshots and forks with memory, private previews, a Linux desktop, metrics, webhooks, OpenTelemetry export, S3, R2 and GCS bucket mounts and identity tokens for AWS and Google Cloud. Paid accounts add custom domains, TCP ports and dedicated outbound addresses. See [products](./products). ## At a glance Blaxel's figures come from its public pricing and documentation, checked 25 October 2026. | | Runtime | Blaxel | | -------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | | Isolation | Firecracker microVM, own kernel | Lightweight virtual machines | | CPU billing | $0.025 per vCPU-hour of measured CPU, with a small floor | Included with memory; 8 GB gets 4 cores | | Memory billing | $0.0075 per reserved GiB-hour | $0.0000115 per GB-second of active time | | Paused storage | $0.08 per GB (10⁹ bytes) a month; files, memory and processes | $0.20 per GB-month snapshot; files and processes | | Plan fee | None; prepaid credit from $10 | None; tiers grow with credit added | | Free start | 100 free hours of a 1 GB machine in the first 30 days, no card | Up to $200 of credit | | Internet out | First 100 GiB a month free, then $0.02 per GB (10⁹ bytes) | Included | ## Cost for the same job Take 1,000 runs of a 2 vCPU, 4 GiB sandbox (4 GB on Blaxel). Each run lasts 60 seconds and keeps the CPU busy for 20 CPU-seconds. This is an agent that spends most of its time waiting for a model. ``` Runtime CPU 1,000 × 20 s / 3,600 × $0.025 = $0.14 Memory 1,000 × 60 s / 3,600 × 4 × $0.0075 = $0.50 Total $0.64 Blaxel Memory 1,000 × 75 s × 4 × $0.0000115 = $3.45 ``` - **Saving:** 81%, or $2.81 per 1,000 runs. - **Per month:** at 100,000 runs, $63.89 on Runtime against $345.00 on Blaxel. - **Busier work:** with both CPUs busy for the whole minute, $1.33 on Runtime against $3.45 on Blaxel. The Blaxel figure includes the 15 seconds or so each sandbox stays billed after its run, before Blaxel puts it on standby. Storage, network, taxes and free credit are left out of both. On Runtime, inbound traffic is free. Each account's first 100 GiB out a month is free. After that, outbound traffic costs $0.02 per GB (10⁹ bytes). See [pricing](./pricing) for Runtime's terms. ## How to switch Give your coding agent the one instruction in [migration](./migrate). It replaces the Blaxel calls on a branch, tests them on the free usage, and tells you what you save each month. Your old code stays on the main branch until you merge. ### Change one import Runtime's SDK runs code written for Blaxel's sandbox SDK. Change the import and set `RUNTIME_API_KEY`, or run `npx withruntime login` once: ```ts no-run import { SandboxInstance } from "withruntime/blaxel"; // was: from "@blaxel/core" ``` ```python no-run from withruntime.blaxel import SandboxInstance # was: from blaxel.core import SandboxInstance ``` Sandboxes get Blaxel's default of 4096 MB, with one vCPU for every 2048 MB. A sandbox pauses after 300 seconds with nothing happening in it. It keeps its memory, files and processes. It wakes by itself on the next command, file call or preview visit. Envs, processes by name, files, previews, snapshots, forks, volumes and the code interpreter carry over. Sandboxes use the free usage first, then prepaid credit. There is nothing to choose. Four things to know before the first run: - **Share sandboxes with an account-wide key.** A Blaxel workspace key sees every sandbox. A Runtime key sees the sandboxes it made unless an owner or admin makes it account-wide, on the [API keys](https://withruntime.com/account/keys) page or with `runtime keys create --account-wide`. Give a web service and a worker that share sandboxes account-wide keys, or one key ([keys in a team](./teams#keys-in-a-team)). - **Without credit, a sandbox is at most 2 vCPU and 4096 MB.** Blaxel's default fits. `memory: 8192` or more needs paid credit. - **Custom images are Runtime images.** Blaxel's own templates, such as `blaxel/base-image`, start Runtime's stock image. Any other image must be a ready Runtime image of the same name. Build it once with `npx withruntime images build --dockerfile Dockerfile --name `. - **Private preview tokens work as they did.** Blaxel's `X-Blaxel-Preview-Token` header and `?bl_preview_token=` parameter are accepted, as are Runtime's own. The token never reaches your server. **Sessions work as they did.** `sandbox.sessions.create()` makes a Runtime [sandbox session](./javascript#a-sandbox-from-a-browser). It is a token your frontend uses to run commands, use files and reach previews of that one sandbox, and nothing else. Blaxel's default of a day is Runtime's longest. `responseHeaders["Access-Control-Allow-Origin"]` names the page that may use it. `SandboxInstance.fromSession(session)` drives the sandbox with the token alone. A call Runtime handles differently, such as drives, schedules or Blaxel's agent and MCP hosting, throws `NotSupportedError` before anything happens. It names what to use instead. `BLAXEL.md` in the package lists every mapping. For MCP, run your own server as a process in the sandbox and reach it through a preview ([MCP servers in a sandbox](./javascript#mcp-servers-in-a-sandbox)). A bucket mounted in several sandboxes shares files between them ([mount your own bucket](./storage#mount-your-own-bucket)). A Blaxel schedule runs a command inside one sandbox. On Runtime, a [scheduled job](./jobs) runs it on a cron schedule or at a time, in a fresh sandbox each run. ### Or port the calls Blaxel: ```js import { SandboxInstance } from "@blaxel/core"; const sandbox = await SandboxInstance.create({ name: "my-sandbox", memory: 4096 }); const result = await sandbox.process.exec({ command: "python3 -c 'print(sum([125, 250, 375]))'", waitForCompletion: true, }); await sandbox.delete(); ``` Runtime: ```ts import { Sandbox } from "withruntime"; const box = await Sandbox.create(); try { await box.files.write("/workspace/invoice.py", "print(sum([125, 250, 375]))\n"); console.log((await box.exec("python3 /workspace/invoice.py", { check: true })).stdout); } finally { await box.stop(); } ``` Moving from Blaxel? Credit you add within 30 days of signing up is matched, up to $1,000 in total ([welcome match](./pricing#welcome-match)). ## When Blaxel may fit better - **Very large fleets.** Blaxel's top tier runs over 100,000 sandboxes at the same time. A tier is the credit topped up over the last 30 days, which is then spent on usage: $20 unlocks 50 sandboxes and $50 unlocks 200. - **Heavy outbound traffic.** Blaxel includes internet egress in its rates. Runtime's first 100 GiB out a month are free. After that, the rate is $0.02 per GB (10⁹ bytes). ## Sources Checked 27 September 2026. - [Blaxel pricing](https://blaxel.ai/pricing) - [Blaxel sandboxes](https://docs.blaxel.ai/Sandboxes/Overview) - [Blaxel usage and quotas](https://docs.blaxel.ai/Security/Quotas) - [Blaxel proxy and network](https://docs.blaxel.ai/Sandboxes/Proxy) - Runtime [pricing](./pricing), [security](./security) and [products](./products) --- # Changelog What shipped in Runtime Cloud, newest first. Runtime ships every day. Each entry is something you can use or see; the guides hold the current terms, and [pricing](./pricing) holds the current rates. Each day, and each entry, also has a page of its own at [withruntime.com/changelog](https://withruntime.com/changelog), and every entry arrives in the [RSS feed](https://withruntime.com/changelog/feed.xml). ## 14 November 2026 - **Catalog MCP servers in a sandbox are withdrawn.** `GET /v1/mcp/catalog` and `/v1/sandboxes/{id}/mcp` return 404 `route_not_found`, with a message that says what to use instead. The SDKs' `mcp` clients, `runtime sandboxes mcp` and the `runtime_sandboxes_mcp` tool are removed. To run an MCP server in a sandbox, start it as a process and share its port with a preview ([MCP servers in a sandbox](/docs/javascript#mcp-servers-in-a-sandbox)). Runtime's own MCP server is unchanged. - **A paused sandbox wakes for its own timers.** A sandbox paused after its idle seconds wakes by itself shortly before the soonest timer of one of its processes is due: a `sleep`, a poll or wait with a timeout, a timerfd, `alarm()` or `timer_create()` that was pending the whole time it sat idle. A loop that polls every ten minutes, or `sleep 600 && ./next.sh`, now runs on time instead of waiting for the next call. Each such wake is billed as any wake. A timer armed and fired again and again while the sandbox was idle wakes nothing, and a sandbox you paused yourself never wakes on its own. - **Environment variables on create.** `POST /v2/sandboxes` and `POST /v1/sandboxes` take `env`: up to 32 variables and 32,768 bytes of names and values, set for every command and process in the sandbox and for its start command. Values are stored encrypted and are never returned. A fork or a copy of a snapshot keeps its source's variables. Before, `/v1` ignored `env` and `/v2` refused it. - **Resize a running sandbox.** `POST /v2/sandboxes/{id}:update` takes `vcpu`, `memoryGB` and `diskGB` (and `POST /v1/sandboxes/{id}:resize` takes `vcpu`, `memoryMiB` and `diskMiB`). The sandbox grows while it runs: its disk and file system, its memory and its vCPUs, with its commands running on. The new size counts against your limits; a size past them returns `403`. A smaller size, or a sandbox that is not running, returns `409` with the reason, and nothing changes. - **Run a sandbox as root with one setting.** `POST /v2/sandboxes` and `POST /v2/sandboxes/{id}:update` take `user`: `runtime` (the default: uid 1000, with passwordless sudo) or `root`. Every exec and process in the sandbox runs as that user when the call names no `user`; a call's own `user` still overrides it for that call. An image kept from a sandbox keeps its user as the default for sandboxes created from it, and a fork or a copy of a snapshot keeps its source's. Every SDK takes it: `create({ user: "root" })`. The per-call value `sandbox` is now `runtime`; `/v1` and the MCP tools still take `sandbox`. - **Job secrets can be deleted, and their names reused.** `POST /v1/secrets/{id}:delete` deletes a job secret (with an optional `expectedVersion`), and `GET /v1/secrets/{id}` returns one. After a delete, `POST /v1/secrets` with the same name creates a new secret. Job secrets are now the same secrets as `/v2/secrets` (visible ones): they appear in its list, and machines get them in their environment as before. - **A fork makes all its copies or none.** `POST /v2/sandboxes/{id}:fork` with `count` is admitted as one start: when the copies together would pass one of your limits (running vCPU or memory) or find no room, the request returns the error and makes no copy. Before, it made copies until the limit and refused the rest. A fork counts as one start against your starts-per-second limit, whatever its `count`. - **Network rules on create.** `POST /v2/sandboxes` takes `network` (the same `{default, rules}` that `PUT /v2/sandboxes/{id}/network` takes), and `POST /v1/sandboxes` and the `runtime_sandboxes_create` MCP tool take `network` in their own shape (`internet`, `allow`, `deny`, `connect`). The rules hold from the sandbox's first start. Before, v1 and MCP dropped `network` and the sandbox started with the default rules. A create from a snapshot refuses `network` with `400`: set the rules with `PUT /v2/sandboxes/{id}/network` after it starts. - **A command runs to its end.** `POST /v2/sandboxes/{id}:exec` (and `/v1/sandboxes/{id}:exec`, and the `runtime_sandboxes_exec` MCP tool) with no `timeoutMs` no longer stops the command after 60 seconds: it runs until it exits and returns its exit code and output. `timeoutMs` still sets a limit when you name one. Every SDK waits for the command to end when you name no limit, with no client timeout, and if the answer is cut while the command runs (for example by a release of the API), the SDK sends the request again with the same idempotency key and returns the same run's result, without running it twice. ## 9 November 2026 - **A sandbox says what you can do with it now.** Every sandbox read from `/v1/sandboxes` and `/v2/sandboxes` returns `actions`: the actions that change it from its current state (`pause`, `wake`, `stop`, `restart` and `delete`). A lifecycle action that cannot apply, such as a pause of a starting sandbox, still returns `409`, and its `details` now carry `state` and the same `actions` list; its hint is written for that state. A wait for a state (`?waitFor=running`) now returns immediately once the sandbox cannot get there without a new action, such as a sandbox you stopped, instead of after its whole timeout. Every SDK reads the list: `sandbox.can("pause")` in TypeScript and Python, `Can("pause")` in Go, `can("pause")` in Java and `can?("pause")` in Ruby. The states and every move between them are on [Sandbox states and actions](/docs/sandbox-lifecycle). ## 8 November 2026 - **A `503 busy` says whether the cell could not confirm the request or could not complete it.** Before, every `503 busy` from `/v1/sandboxes` read "The cell could not confirm this in time; it may or may not have been applied.", whatever the cause. That message now comes only when the cell's log or a timeout is the reason. Any other cause, such as a sandbox's disk or its guest not answering, returns "The cell could not complete this right now; it may or may not have been applied." The status, the `busy` code and `details.reason` are unchanged. Retry with the same `Idempotency-Key`. ## 7 November 2026 - **Desktops: capture the screen with `POST /v2/desktops/{id}:screenshot` and drive it with `POST /v2/desktops/{id}:act`.** A screenshot returns the whole screen as base64, as `png` or `jpeg` (set `quality`, 1 to 100), with its width and height. An action moves, clicks, drags or scrolls the mouse by pixel, types text, presses xdotool key names such as `ctrl+l` or `Return`, reads the cursor position, lists the windows and focuses one, opens a URL in the desktop's Chromium, or launches a program. Both run inside the desktop's own sandbox. Returns `400` (`invalid_request`) when a field is outside its limits, `409` (`desktop_not_running`) when the desktop is not running, and `409` (`desktop_failed`) with the command's own error when it fails. Available in every SDK as `desktops.screenshot` and `desktops.act`. - **Apps, Functions and Containers: volumes are mounted.** A version whose `spec.volumes` names a volume now mounts it in each copy before its process starts. Before, the version failed with `deploy_build_failed: volumes cannot be read here yet`. A volume that is still being created is waited for; one that does not exist, or was deleted after the request was accepted, fails the version with `not_found`. A `POST`, `PUT` or `PATCH` that sets `spec.volumes` needs `volumes:attach`; without it, the request returns 403. - **Sandboxes: mount a volume through v2.** `POST /v2/sandboxes` accepts `spec.volumes`, a list of at most one `{volume, path}` entry naming a volume of the same project by its ID (`vol_...`). The volume is mounted read-write at its `path` before the sandbox's phase is `running`; a volume that is still being created is waited for. The sandbox writes the volume alone: while it holds it, no other sandbox mounts it, and it cannot be forked. The API key needs `volumes:attach`, and must be able to read the volume: without `volumes:attach` the request returns 403, and a volume the key cannot read, or that does not exist, returns 400. `volumes` is set when the sandbox is created and cannot be changed. Each `path` must be absolute, outside `/proc`, `/sys` and `/dev`, and neither at nor inside a file share's path; otherwise the request returns 400. ## 5 November 2026 - **Status incidents in the API.** `GET /v2/status/incidents` returns the incident notes shown on withruntime.com/status, `status.md` and `status.json`, newest start first, as `{"data": [...]}`. It requires no API key. Each incident has `id`, `title`, `start`, `end` (`null` while ongoing), `saw` and `fixed`; a published note is never rewritten, and later updates are appended to `fixed`. The status page now reads these notes live, so a new or updated incident appears on the page's next load. - **Per-address limit on creating organizations.** `POST /v2/orgs`, which creates a signed-in person's own organization, is limited to 10 an hour per client IP address (an IPv6 address counts by its /64). A request over the limit returns `429 rate_limited` with a `Retry-After` header giving the seconds until one more request is accepted; the refused request does not count toward the limit. - **Closing an account deletes its resources.** `POST /v1/account:close` now succeeds for every account; for some accounts it previously returned `500 internal_error`. From the response on, requests with the account's API keys to sandboxes, volumes and the other resource APIs return `401`, and the account's resources are deleted within seconds. Data-bearing resources, such as volumes and snapshots, are held for 7 days before they are purged. The response's `released` field is `false` while that deletion is in progress. - **Cloud Run: Google's client libraries' `$alt` parameter is accepted.** The Google Cloud Run Admin API at `/compat/google-cloud-run` now accepts the `$alt` query parameter (`json`, or `json;enum-encoding=int`), which Google's Node.js client (`@google-cloud/run`) sends with every REST request. Previously each such request returned 400. Responses are JSON, with enum values as names, which the clients read. Any other `$alt` value returns 400. - **Seven more launch checks through competitors' APIs on /status.json.** `launch_checks` on `GET https://withruntime.com/status.json` now also returns `dev_environments_codespaces` (Octokit, GitHub's SDK, against the Codespaces API), `containers_fly` (Fly's Machines API), `containers_cloud_run` (Google's Cloud Run client for Node.js), `firewall_security_groups` (the AWS SDK for JavaScript's EC2 client), `uptime_checks_betterstack` (Better Stack's monitors API), `uptime_checks_checkly` (Checkly's checks API) and `sites_vercel` (Vercel's SDK, promoting a release). Each entry creates, uses, reads back and deletes one resource through Runtime's compatible API, with the same fields as the other launch checks. For a product billed per resource (`dev_environments_codespaces`), the entry also confirms that the resource's usage was billed, and its `steps_ms` includes `usage`. The other six products are not billed per resource, so their entries have no `usage` step. - **`GET /v1/me` returns the key's reach.** The response now includes `reach`: `agent` when the key acts only on what its own agent made, `compartment` when it acts on what every key of its compartment made, or `account` when it acts on everything in the account. The value reflects the key as the API treats it at the time of the request, so an account-wide key whose maker is no longer an owner or admin returns `agent`. Every SDK returns it from `me()`. - **A build whose identical image is larger than its `maxImageMiB` fails with the size to set.** When your account already had an identical image larger than a new build's `build.maxImageMiB` (8192 by default), the build failed with "The image could not be saved because of a fault on our side". It now fails immediately with `An identical image of this organization is N MiB, larger than this build's build.maxImageMiB of M. Set build.maxImageMiB to at least N.` Set `build.maxImageMiB` to at least that size and the build copies the identical image immediately. - **Per-address limits on sign-in and API key creation.** Requests that sign in or create an account are limited per client IP address (an IPv6 address counts by its /64): 30 an hour for `POST /v2/sign-in/email`, and 30 an hour for `POST /v2/sign-in/email/verify` and Google or SSO sign-in returns together. Creating or rotating API keys (`POST /v2/keys`, `POST /v1/keys`) is limited to 60 an hour. Each API server applies these limits on its own. A request over a limit returns `429 rate_limited` with a `Retry-After` header giving the seconds until one more request is accepted; nothing is created, and the refused request does not count toward the limit. Accounts that already exist count the same as new ones. - **Clearer error when a sandbox from an image cannot start.** When the server could not prepare a sandbox's image, `POST /v1/sandboxes` returned `503 busy` with "The cell could not confirm this in time; it may or may not have been applied." It now returns `503 busy` with "This sandbox could not be started on its server, so nothing was made." Retry the create with the same `Idempotency-Key`. - **Render environment variable endpoints for Apps.** `/compat/render/v1` now serves Render's environment variable endpoints over an App's `spec.env`: list (`GET /services/{serviceId}/env-vars`, paginated by `cursor` and `limit`), replace all (`PUT /services/{serviceId}/env-vars`), get, set and delete one (`GET`, `PUT` and `DELETE /services/{serviceId}/env-vars/{envVarKey}`). Setting or deleting one variable leaves the others unchanged. Getting or deleting a variable the service doesn't have returns 404. `generateValue` returns 400. - **Sandboxes, Apps and Functions can call App and Function addresses.** A request from a sandbox, an App or a Function to an App's or a Function's address (`https://.runtimehost.com`) used to return `403` with "Destination resolves to a forbidden address". These addresses are now reachable over HTTPS on port 443, as they are from the internet. Private address ranges, Runtime's own servers, and other ports on these names are still refused. - **Render list pagination reads only the requested page.** On `/compat/render/v1`, `GET /services` and `GET /services/{serviceId}/deploys` now start reading from the position the `cursor` names and stop as soon as the page is full. Previously each request read the whole list first. Without a `name` or `status` filter, a request reads only as many items as the page returns. If the item a cursor names is deleted before the next request, that request returns the next page instead of 400. The page may repeat items, but no item is skipped. Cursors are opaque strings, and cursors returned before this change continue to work. A `cursor` value that the list did not return returns 400. - **Paused sandboxes started from a custom image survive the loss of their host.** A paused sandbox started from your own image is no longer stopped when its host is lost, as long as its saved copy is complete and another host holds the image. Its next wake restores it from that copy on a host that holds the image. While no such host can take it, a wake returns `503 no_capacity` with the detail that it starts from its saved copy as soon as one has room. If the image is deleted or rebuilt, or no other host holds it, the sandbox is stopped as before, and its parked charge ends when its host was lost. - **A wake whose host does not answer fails within about a minute.** A paused sandbox whose host did not answer its wake used to stay in `resuming` for as long as the host stayed silent, and every request on it waited. The wake now gives up after two unanswered attempts (about a minute): requests on the sandbox return `500 operation_failed` immediately, its memory and files are kept, and nothing is charged. A minute later the host is asked again; once it answers, the sandbox is `paused` and the next request wakes it. - **App versions and Render deploys.** `GET /v2/apps/{id}/versions` lists an app's versions, newest first and paginated by `limit` and `cursor`. `GET /v2/app-versions/{id}` returns one version. A version is numbered by the app generation that created it, so the response to the `PATCH` that creates a version identifies it. A new `spec.rollout` value creates a new version with the same settings. Render's deploy endpoints are now served under `/compat/render/v1`: list deploys (filterable by `status`), get a deploy, create a deploy (`imageUrl`, or a redeploy of the current image), and roll back to an earlier deploy (`POST /services/{serviceId}/rollback`). A deploy's ID is its version number. Creating a deploy, or changing the image with `PATCH /services/{serviceId}`, clears an earlier rollback, so the new version goes live. `commitId` and `buildId` return 400, and cancelling a deploy is not supported. ## 4 November 2026 - **Render API for Apps.** The web service endpoints of Render's REST API are served under `/compat/render/v1`, so Render API clients and the `render` CLI manage Apps by changing only their host (`RENDER_HOST`) and API key (`Authorization: Bearer `). Supported: create an image-backed web service (`POST /services`, `type` `web_service`, `runtime` `image`), list, get, update its name, image, plan and health check path, scale (`POST /services/{serviceId}/scale`) and delete. Lists return `[{"cursor", "service"}]` and are paginated with `cursor` and `limit` (20 by default, at most 100), and can be filtered by `name`. The plans `starter` (the default), `standard`, `pro`, `pro_plus`, `pro_max` and `pro_ultra` create an App with 1, 1, 2, 4, 4 or 8 vCPUs and 0.5, 2, 4, 8, 16 or 32 GiB of memory, billed by measured use; each copy serves on port 10000, Render's default `PORT`. Errors return Render's status codes and `{"id", "message"}` body. A field or operation Apps does not support yet, such as `repo` or deploys, returns 400 or 404. - **Desktops: E2B Desktop API.** Desktops now accept E2B's sandbox REST API with its `desktop` template at `/compat/e2b-desktop`: set `E2B_API_URL` to `https:///compat/e2b-desktop` and `E2B_API_KEY` to a Runtime API key, and E2B Desktop's SDKs (`@e2b/desktop`, `e2b_desktop`) can get, list (paginated by `nextToken`), connect to, extend the timeout of, pause and kill Runtime desktops. Creating a desktop from the `desktop` template with `timeout` and `metadata` is supported over the REST API; a timeout under 60 seconds returns 400. The SDKs' screen, mouse, keyboard, screenshot and stream methods, and their `Sandbox.create()`, which starts the screen through commands inside the sandbox, are not yet available. Desktops also gain `spec.endsAt` (a later time extends the timeout), and Browsers and Desktops gain `status.endsAt`. - **Launch checks on /status.json.** `GET https://withruntime.com/status.json` now returns `launch_checks`, one entry per product that launches with the next platform release (Browsers, Desktops, Notebooks, Dev environments, Actions runners, Cron, Apps, Functions, Containers, Load balancers, Firewall, Uptime checks, KV, Queues and Sites). Each hour, a test account creates, uses, reads back and deletes one resource of each product through the public API and confirms that its usage was billed. Three more entries run the same steps through a competitor's own SDK against Runtime's compatible API: `browsers_browserbase` (Browserbase's Node.js SDK), `kv_cloudflare` (Cloudflare's SDK) and `functions_lambda` (the AWS SDK for JavaScript's Lambda client). Each entry returns `state` (`passed`, `failed` or `not_served`), `checked_at`, `total_ms`, and `steps_ms` with the time of each step; a failed entry also returns `failed_step` and `error`. - **Upstash QStash schedules API for Cron.** QStash's schedules endpoints are served under `/compat/qstash`, so the `@upstash/qstash` client manages Cron jobs by changing only its base URL and token. Supported: create (`POST /v2/schedules/{destination}` with `Upstash-Cron`, `Upstash-Method` and the request body), get, list, pause, resume and delete. Each schedule is a job that sends the request to the destination on the cron schedule in UTC, with up to 3 retries, and is billed by measured use. A header not supported yet, such as `Upstash-Retries` or `Upstash-Forward-*`, returns 400 with its name. Errors return QStash's status and `{"error"}` body. - **Balancer targets receive requests only after their first health check passes.** When a balancer has `spec.health`, each target starts out of rotation: a new target, or every target on a host whose router has just restarted, receives no requests until its first health check passes, which happens within about 100 ms. One passing check is enough, whatever `healthyThreshold` is. While no target has passed yet, a request waits for the first check result instead of going to every target. If no target passes, requests go to all targets, as before. A target whose first check fails is reported unhealthy immediately. - **Start action for Dev environments.** `POST /v2/dev-environments/{id}:start` starts an environment immediately: a paused environment is woken, and a stopped one starts again from its disk, even when `spec.state` is already `running`. If `spec.state` holds another state, it is set to `running`. The response is the environment. Returns 409 (`environment_stopping`) while a stop is in progress, and 409 (`environment_not_resumable`) when the environment's disk was not kept. Every SDK has a `start` method. GitHub Codespaces' start (`POST /user/codespaces/{codespace_name}/start` under `/compat/github`) uses it, so it also starts a codespace that was shut down for being idle. - **Cloud Run: `gcloud run revisions list --service`.** In the Google Cloud Run Admin API at `/compat/google-cloud-run`, v1's `namespaces.revisions.list` now returns a service's revisions when `labelSelector` is `serving.knative.dev/service=`, as `gcloud run revisions list --service ` sends it. Each revision is a version of the Container, as a Knative Revision, and only revisions of the service in the request's project and location are returned. A request without that selector, such as `gcloud run revisions list` with no `--service`, or with any other selector, returns 400. The v1 `namespaces.revisions.list` and `namespaces.services.list` methods also accept `limit` and `continue`: with either, the response is one page, and the next page's token is in `metadata.continue`. Without them, the response holds every item. A repeated `labelSelector`, `limit` or `continue` returns 400. - **Containers: versions, and Cloud Run revisions.** `GET /v2/containers/{id}/versions` returns a container's versions, newest first and paginated with `limit` (1 to 100, default 20) and `cursor`. `GET /v2/container-versions/{id}` returns one version by its ID, which is the container's ID, a dot, and the version's number (`ctr_....3`). Both accept the container's slug in place of its ID and require the key's `containers:get` permission. Each version returns its `number`, `state` (`building`, `warming`, `ready`, `live`, `failed` or `superseded`), `image`, `imageDigest`, `failure`, the container `generation` that made it, and the container's `labels`. The SDKs read them with `containers.versions(id)` and `containers.version(id)`. In the Google Cloud Run Admin API at `/compat/google-cloud-run`, `services.revisions.list` (paginated with `pageSize` and `pageToken`) and `services.revisions.get` now return these versions as revisions named `-`, and v1's `namespaces.revisions.get` (`gcloud run revisions describe`) returns one as a Knative Revision. A revision of another service, location or project returns 404. Deleting a revision is not yet available, and v1's revisions list returns 404. - **Sandboxes: a fork waits for room, as a create does.** When no server has room for a copy, `fork` now retries with the same idempotency key for up to two minutes, as `create`, `wake` and `restart` do, in every SDK and in `withruntime sandboxes fork`. A fork refused for room pauses nothing and charges nothing. Set the wait as for a create (`waitForCapacityMs` in JavaScript, `wait_for_capacity` in Python and Ruby, `WaitForCapacity` in Go, `waitForCapacity` in Java); 0 fails immediately, and the CLI's `--no-wait` fails immediately. A fork of one copy now needs only the room a create of the same size needs; before, it needed four times its memory free on one server. A fork of many copies is still held to a quarter of each server's free memory past its first copy there, and is refused with `details.fits` when fewer copies fit. A fork that has already failed returns `retryable: false`, because its idempotency key returns the same error. - **Containers: Cloud Run revisions are named, and traffic can be pinned to one.** In the Google Cloud Run Admin API at `/compat/google-cloud-run`, each version of a Container is a revision named `-`, such as `hello-3`. In v2, a service now returns `latestCreatedRevision` (the newest version), `latestReadyRevision` (the version serving requests) and `trafficStatuses`. In v1, it returns `status.latestCreatedRevisionName`, `status.latestReadyRevisionName` and `status.traffic[].revisionName`. Sending 100% of traffic to one revision sets the Container's `spec.version`, which rolls it back to that version: in v2, `traffic` with `type: TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION` and `revision`; in v1, `spec.traffic` with `revisionName`, as `gcloud run services update-traffic --to-revisions hello-2=100` sends. Traffic to the latest revision (`TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST`, or `latestRevision: true`) removes the pin. A revision of another service, a revision without a version number, or a revision named in a create returns 400. A traffic split between revisions returns 400, and `revisions.list`, `revisions.get` and `revisions.delete` are not yet available. - **Better Stack TCP monitors keep their type.** In the Better Stack compatibility API, a `PATCH` that changes a TCP monitor's `monitor_type` to `status`, `keyword` or `expected_status_code` now returns 400; previously it changed only the reported type while the check kept probing TCP. Delete the monitor and create a new one to change its type. - **Better Stack TCP monitors.** The Better Stack compatibility API at `/compat/betterstack` now accepts `monitor_type: "tcp"` with `url` (the host) and `port`, and creates an Uptime check on `tcp://:`; the monitor returns its host and port as Better Stack does. A TCP monitor without `port` returns 400. A TCP monitor's host and port, and a change to or from `tcp`, can't be updated, because an Uptime check's URL is fixed: such a request returns 400 (a `port` must be sent with its `url`); delete the monitor and create a new one. - **Better Stack expected-status-code monitors.** The Better Stack compatibility API at `/compat/betterstack` now accepts `monitor_type: "expected_status_code"` with `expected_status_codes` (a list of HTTP status codes); the Uptime check passes only those codes, and the monitor returns them as numbers. A request with that type and no codes returns 400. On other monitor types the codes are ignored, as on Better Stack, and changing a monitor to another type restores the default passing statuses (200 to 399). - **Browserbase sessions accept `keepAlive`.** `POST /compat/browserbase/v1/sessions` accepts `keepAlive: true` (previously 400). On Browsers, every session runs until its `timeout` (default 3600 seconds) or until you release it with `REQUEST_RELEASE`, whether or not a client is connected. A session created without `keepAlive` also keeps running after its client disconnects. To stop billing for it, release it or set `timeout`. - **Containers: a `slug` addresses a container, and Cloud Run services keep the name requested.** Containers accept an optional `spec.slug`: 1 to 63 lowercase letters, digits and hyphens, starting with a letter and not ending with a hyphen, unique within the project and set at create only. A create that repeats a slug in use returns 409 `conflict`, and the slug is released when the container is deleted. `GET`, `PATCH` and `DELETE /v2/containers/{id}` accept the slug in place of the ID; other kinds whose `name` is unique within the project, such as queues, accept that name the same way. A request by name requires the key's `get` permission on the kind. In the Google Cloud Run Admin API v2 at `/compat/google-cloud-run`, `services.create` now stores `serviceId` as the container's slug: the service's `name` is `projects/{project}/locations/{location}/services/{serviceId}`, and `services.get`, `services.patch` and `services.delete` accept that name. A second create with the same `serviceId` in the project returns 409 instead of returning the first service; a service created without `serviceId` is named by its container ID, as before. A slug is unique within a project, not within each location. A service's `description` now returns 400 instead of being stored as the container's `name`. - **Browserbase session list rejects an unknown status.** `GET /compat/browserbase/v1/sessions` returns 400 when `status` is not one of `RUNNING`, `ERROR`, `TIMED_OUT`, `COMPLETED` or `PENDING` (the values are case-sensitive), as Browserbase does. Previously it returned an empty list. - **Console: the Sandboxes, Browsers and Desktops lists show every sandbox, wherever it runs.** For many accounts the lists showed only sandboxes made before the account moved to Runtime's new servers, and none made since. The lists now include them for every account. A sandbox that moved while paused shows its current state; before, it showed paused, and a woken one was missing from the Running tab. "Stop all long-running" reads the same list. ## 3 November 2026 - **Better Stack's monitors API over Uptime checks.** Better Stack Uptime API calls for monitors (`GET` and `POST /api/v2/monitors`; `GET`, `PATCH` and `DELETE /api/v2/monitors/{id}`) run against Runtime Uptime checks at `/compat/betterstack` with a Runtime API key as the bearer token. Status and keyword monitors are mapped: URL, name, HTTP method, check frequency, request timeout, regions and required keyword. Any other field set in a request, including `paused: true` and `follow_redirects: true`, returns 400 with a message that names the field. Only a `keyword` monitor checks `required_keyword`, as on Better Stack; a keyword monitor requires it, and a monitor changed to `status` stops checking for it. A monitor's `url` can't be changed; delete the monitor and create a new one. - **GitHub Codespaces API for Dev environments.** The Codespaces endpoints of GitHub's REST API are served under `/compat/github`, so Octokit and other GitHub clients manage Dev environments by changing only their base URL and token (`Authorization: token ` or `Bearer `). Supported: create in a repository (`POST /repos/{owner}/{repo}/codespaces`), list, list in a repository, get, update the display name, start, stop, delete, and machine types for a repository or a codespace. Lists are paginated with `page` and `per_page` (30 by default, at most 100), return the full count in `total_count`, and return a `Link` header to the next and last pages. The machine types `basicLinux32gb`, `standardLinux32gb` (the default), `premiumLinux` and `largePremiumLinux` create an environment with 2, 4, 8 or 16 vCPUs and 8, 16, 32 or 64 GiB of memory, billed by measured use. Stop pauses the environment with its memory kept. A field Dev environments does not support yet, such as `devcontainer_path`, returns 400 with its name. - **Containers: Google Cloud Run Admin API v2.** Containers now accept the Cloud Run Admin API v2 services methods at `/compat/google-cloud-run`: set the client's endpoint to `https:///compat/google-cloud-run` and use a Runtime API key as the bearer token. `services.create`, `services.get`, `services.list` (paginated with `pageSize` and `pageToken`), `services.patch` and `services.delete` are supported; each service is a Container labeled with its project and location, and a service under another project or location returns 404. One container's `image`, `command`, `args`, `env` (name and value pairs), `workingDir`, one port, `resources.limits.cpu` (1, 2, 4 or 8) and `resources.limits.memory` (512Mi to 32Gi), `scaling.minInstanceCount`, `scaling.maxInstanceCount` and `maxInstanceRequestConcurrency` map to the Container's spec, with Cloud Run's defaults: a service scales to zero unless `minInstanceCount` is set. Any other field returns 400 with a message naming it. Create, patch and delete return their operation with `done: true`, so clients do not poll. `services.patch` replaces the whole template, so its body must include `template.containers[0].image`; `updateMask` and `etag` return 400. A service's name holds its Container ID, not the `serviceId` requested; `serviceId` follows Cloud Run's rule (1 to 49 lowercase letters, digits and hyphens, starting with a letter) and is the create's idempotency key for 24 hours, so a retried create returns the same service instead of creating a second one; once that service is deleted, a create with the same `serviceId` creates a new one. Revisions, traffic other than 100% to the latest revision, `validateOnly`, `allowMissing` and the IAM methods are not yet available. - **Containers: flyctl and fly-go.** flyctl and Fly's Go client (fly-go) now run against Runtime's Fly Machines API unchanged. These clients discard the path of `FLY_FLAPS_BASE_URL` and send `/v1/apps/...`, so the `/compat/fly` base URL from the earlier entry did not reach them. Requests whose `User-Agent` starts with `flyctl/` or `fly-go/`, or that include flyctl's `Fly-Client-Parent` header, are now served Fly's API at the root of the API host. Set `FLY_FLAPS_BASE_URL`, `FLY_API_BASE_URL` and `FLY_UIEX_BASE_URL` to `https://api.withruntime.com` and `FLY_API_TOKEN` to a Runtime API key, so that no flyctl request is sent to Fly. `fly machine run`, `list`, `stop`, `start` and `destroy` are supported. `GET /v1/apps/{app_name}`, the organization lookup and the image lookup that `fly machine run` performs are supported. `GET /v1/apps/{app_name}/machines/{machine_id}/wait` returns 200 when the machine is in a requested `state`, at the requested `version` or `instance_id` if one is given, and 408 immediately when it is not, because a Container's state changes only through a request. `from_event_id` is accepted and ignored. `GET /v1/apps/{app_name}` returns 200 for any app name. A stop request now accepts `id`, `timeout` and `signal: "SIGTERM"`; any other signal returns 400. A machine's `instance_id` is a string, as Fly's clients expect. Other HTTP clients continue to use `https://api.withruntime.com/compat/fly` as their base URL. - **Queues: SQS-compatible status codes for authentication and size errors.** The SQS-compatible endpoint now returns 403 for `AccessDenied` and for every `InvalidClientTokenId`, and 413 for `RequestEntityTooLarge`; these errors previously returned 400 (and `InvalidClientTokenId` returned 400 or 403 depending on where the request was refused). `AddPermission` and `RemovePermission` are not supported and return 400 `UnsupportedOperation`: access to a queue comes from the access key's grant. - **Functions: AWS Lambda `Invoke`.** The AWS Lambda API on Runtime Functions now supports `Invoke`, so `invoke` in AWS's SDKs runs a function after you change only the endpoint and the keys. The payload is sent as a `POST` to the function's address (`status.url`). With `InvocationType` `RequestResponse` (the default), the call returns 200 with the function's response body. When the function returns a 4xx or 5xx status, the response also includes `X-Amz-Function-Error: Unhandled`. A function whose response is larger than 6 MB, whose connection closes before it responds, or that runs longer than 15 minutes also returns 200 with `X-Amz-Function-Error: Unhandled`, and an `errorType` of `Function.ResponseSizeTooLarge`, `Runtime.ExitError` or `Sandbox.Timedout`, so SDKs do not retry an invocation that may have run. When the function cannot accept the request, `Invoke` returns 429 `TooManyRequestsException` or 500 `ServiceException`. `DryRun` returns 204 without running the function, including for a function that has no address yet. `Event` is not yet supported and returns 400 `InvalidParameterValueException`. A function that has no address yet returns 409 `ResourceConflictException`. A payload larger than 6 MB returns 413 `RequestTooLargeException`. Concurrent invocations are limited per organization; past the limit, `Invoke` returns 429 `TooManyRequestsException`. `Qualifier`, `LogType: Tail` and `ClientContext` are not supported. - **Browserbase session list filters on Browsers.** `GET /compat/browserbase/v1/sessions` (`sessions.list` in Browserbase's SDK) accepts `status` (`PENDING`, `RUNNING`, `COMPLETED` or `ERROR`) and `q` in Browserbase's metadata query form, `user_metadata['key']:'value'`, and returns only the sessions that match both. A session whose browser is still starting now reports `PENDING` instead of `RUNNING`. A `q` in any other form, including a nested path such as `user_metadata['a']['b']:'value'`, returns 400: session metadata on Browsers holds only string values at the top level. - **Browserbase's session debug URLs on Browsers.** `GET /compat/browserbase/v1/sessions/{id}/debug` (`sessions.debug` in Browserbase's SDK) returns the session's `wsUrl` (its Chrome DevTools Protocol WebSocket URL) and, when the browser draws its screen, its live view as `debuggerUrl` and `debuggerFullscreenUrl`; otherwise those are empty. `pages` is always empty; the browser's pages are listed at its `httpUrl` (`/json/list`). - **Queues: SQS-compatible error status codes.** The SQS-compatible endpoint now returns the HTTP status Amazon SQS returns for each error. For example, `DeleteMessage` with an invalid receipt handle and `CancelMessageMoveTask` for a task that does not exist now return 404 instead of 400. Every operation and error is checked against AWS's published SQS API model. - **Checkly's checks API over Uptime checks.** Checkly API calls for API and URL checks (`POST /v1/checks/api`, `POST /v1/checks/url`, `PUT` on each, `GET /v1/checks`, `GET` and `DELETE /v1/checks/{id}`) run against Runtime Uptime checks at `/compat/checkly` with a Runtime API key as the bearer token. Frequency, locations, maximum response time, the URL, the method and one status-code assertion are mapped; any other field set in a request returns 400 with a message that names the field. - **Balancers and Ports: target weights and failover.** Each entry in `spec.targets` accepts `weight` (1 to 1,000, default 1) and `backup` (default `false`). When targets have different weights, each request (or, for a port, each connection) goes to a target with a probability of its weight over the sum of the weights; when weights are equal, it goes to the target with fewer requests in flight of two chosen at random. On a balancer, a backup target receives requests only while no other target is healthy and has capacity; on a port, it receives connections only while every other target's sandbox is stopped or deleted. Available in every SDK. - **Apps, Functions and Containers: health checks while serving.** `healthPath` is now also checked while a copy serves: every 5 seconds, each copy is sent a GET of the path, and a copy that fails 2 consecutive checks (no status under 500 within 2 seconds) receives no requests until it passes 2 consecutive checks. Previously the path was checked only before a copy took its first request. During a staged release that changes `healthPath`, the service's address checks only that each copy's port accepts a connection until the release completes. A `healthPath` that does not start with `/` is checked as `/`, as the check before the first request already does. A path with characters a URI path cannot contain, such as a space or `#`, is not sent: the check only confirms that the copy's port accepts a connection. - **Containers: Fly Machines restart.** `POST /compat/fly/v1/apps/{app_name}/machines/{machine_id}/restart` is now supported. It creates a new version of the machine's Container with the same settings, and that version's copy replaces the running one. The `timeout` query parameter is accepted. `signal` returns 400, because the restart replaces the copy instead of signaling its process. As with the other machine routes, a machine is found only under its own app. - **Speed page: daily history charts and provider comparison.** The [speed](./speed) page now charts each listed operation's median own time for each of the last 90 days, against its physical floor. Hover over a day to see its p95, p99 and sample count. When another provider has been measured the same way and Runtime is faster, the page also lists it beside the matching Runtime operation, comparing total times. - **CI/CD: a job that fails because its machine ran out of disk says so in its reason.** When a job's machine runs out of disk, the error is often never written to the job's log, because the log is on the same full disk. Such a job's reason was the log's last error, for example `error: build failed, waiting for other jobs to finish...`. The job's log now ends with a line that says the machine ran out of disk (`error: No space left on device: ...`), and its reason names that line. - **Speed: browser create time and rival providers, measured the same way.** `browsers.create` is now measured every five minutes. The time runs from `POST /v1/browsers` until the browser's CDP WebSocket answers `Browser.getVersion`, which is the point where Playwright or Puppeteer can connect. The CDP exchange carries no `Server-Timing`, so all of its time is counted as Runtime's own. `speed.json` also includes a `rivals` array. Each rival is timed from the same location as Runtime, through the provider's own public API, to the same outcome as the Runtime operation it is compared with (`compares`). Browserbase, Steel, Hyperbrowser and Browser Use are timed until CDP answers. E2B, Daytona and Vercel Sandbox are timed until the sandbox is running. Rivals return no `Server-Timing`, so each line reports total time only. A rival is listed only after it has been measured with its own API key; nothing is estimated. Each rival's history is available from `speed/history.json?op=rivals..` ([speed](./speed)). - **Sites (beta): static websites served with no machine.** A site is files and a route table (redirects, rewrites, headers, Markdown for agents, 404 pages) served by every front. `POST /v2/sites` creates one; `:missing` returns the files, by SHA-256, the site does not hold yet, `:upload` stores each, `:release` stages a release and `:publish` makes it live in one commit. `:rollback` publishes the previous release, also in one commit, and keeps the release it replaced so it can be published again. Both `:publish` and `:rollback` accept `ifVersion` and return 409 if another change came first. `:releases` returns the live release, the earlier ones and the ones rolled back from. A deleted site stops serving and keeps its releases; restoring it serves its live release again. `npx withruntime sites deploy ./dist` runs all of these for a folder and uploads only the files that changed. Every SDK, the CLI and the MCP tools include Sites. Usage is billed on the existing storage, operations and egress meters at their current rates. - **Speed history by the hour and by the day.** Each listed operation's hourly and daily p50, p95 and p99 (Runtime's own time, the network's part and the total), sample count and failure count are now kept indefinitely and returned by `https://status.withruntime.com/speed/history.json?op=&step=hour|day&from=&to=`. `from` and `to` take a date (`2026-11-03`) or an ISO 8601 time and are rounded down to the step; by default a request returns the last 48 hours, or the last 365 days with `step=day`. A request returns at most 744 hourly or 3,660 daily points, and returns 400 for an unknown operation or an invalid range. Percentiles are computed from each hour's or day's own samples. Responses are cached for one minute ([speed](./speed)). - **Containers: Fly Machines API.** Containers now accept Fly's Machines REST API at `/compat/fly/v1`, so clients written for `api.machines.dev` run against Runtime after you change only the base URL and the token: use `https:///compat/fly` as the base URL and a Runtime API key as the bearer token. For flyctl's Machines calls, set `FLY_FLAPS_BASE_URL` to the same base URL. Each machine is a Container with one copy, labeled with its app. Creating, showing, updating and deleting a machine, listing an app's or an organization's machines, starting, stopping and suspending a machine, and reading and writing its metadata in all six forms are supported. A machine's `image`, `env`, `guest.cpus`, `guest.memory_mb`, `init.cmd`, `init.entrypoint`, `metadata`, one service's `internal_port`, one mount and `restart.policy` (`no` or `always`) map to the Container's spec; any other config field returns 400 with a message naming it. As on Fly, an update replaces the machine's whole config, so a field it omits is removed; listing machines returns every machine, not one page; and a machine ID is found only under its own app, so a request under another app returns 404. `auto_destroy: true` returns 400. Restart, wait, events, versions, exec, signal, processes, leases, cordon and the memory routes are not yet available and return 404. - **Sandboxes: E2B API.** Sandboxes now accept E2B's REST API at `/compat/e2b`, so code that uses E2B's SDKs (`e2b`, `@e2b/code-interpreter`) can create and manage Runtime sandboxes after you change only the API URL and the API key: set `E2B_API_URL` to `https:///compat/e2b` and `E2B_API_KEY` to a Runtime API key. Creating a sandbox from the `base` template with `timeout`, `metadata` and `autoPause`, getting and listing sandboxes (paginated by `nextToken`), setting the timeout, pausing, connecting (which resumes a paused sandbox) and killing are supported. Running commands and reading or writing files inside the sandbox through E2B's SDK are not yet available. A request that sets an unsupported field, such as `envVars` or another template, returns 400 with a message naming the field. - **Browserbase's sessions API on Browsers.** Browserbase's Node.js SDK (`@browserbasehq/sdk`) works with Runtime Browsers when `BROWSERBASE_BASE_URL` is set to `https:///compat/browserbase` and `BROWSERBASE_API_KEY` to a Runtime API key. `POST /v1/sessions` returns a running session with its `connectUrl` (the browser's Chrome DevTools Protocol WebSocket URL), `GET /v1/sessions/{id}` and `GET /v1/sessions` read and list sessions (a list of more than 100,000 sessions returns 400), and `POST /v1/sessions/{id}` with `status: "REQUEST_RELEASE"` ends one. Fields and operations not yet supported (`region`, `keepAlive`, proxies, contexts, debug URLs, recordings) return 400 or 404 instead of being ignored. - **Functions: AWS Lambda API.** Functions now accept the AWS Lambda API, signed with AWS Signature Version 4, so code that uses AWS's SDKs (such as boto3) runs against Runtime Functions after you change only the endpoint and the keys. Create an access key pair with `POST /v2/aws-access-keys`, authenticated with a Runtime API key; it returns `accessKeyId` and `secretAccessKey`, reaches what that API key reaches, and stops working when that API key is revoked or expires. Set `AWS_ENDPOINT_URL_LAMBDA` to `https://`, set `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` to the pair, and use a function's ID as `FunctionName`. `GetFunction` (`FunctionName`, `MemorySize`, `Environment` and `LastModified`), `ListFunctions` with `Marker` and `MaxItems`, and `DeleteFunction` are supported. Errors return Lambda's exception names, such as `ResourceNotFoundException`. A qualified function name or an ARN returns 400 `InvalidParameterValueException`. `Invoke`, `CreateFunction`, `UpdateFunctionCode`, `UpdateFunctionConfiguration`, versions and aliases are not yet available and return 404. - **Notebooks: connect JupyterLab, VS Code or any Jupyter client.** The URL that `POST /v2/notebooks/{id}:open` returns now also accepts its secret as a Jupyter token, sent as `Authorization: token ` or `?token=`. Jupyter clients use Jupyter Server's REST API (`/api/kernels`, `/api/sessions`, `/api/contents`) and the kernel WebSocket without changes, and share the kernel with cells run through `:execute`. - **Firewalls.** `POST /v2/firewalls` creates a named rule set that ports and balancers attach to with `firewallId`. Each rule blocks, allows or rate-limits HTTP requests by IP address or CIDR block, path, method or header; a rule that matches ports applies to raw TCP connections only. Rules are checked at write by the same parser and compiler every host enforces them with. A change to a firewall applies to every route attached to it one commit later, without a write to the routes. A rate limit counts each client across every route attached to the firewall. A route with `firewallId` takes no inline `firewall`; the request returns 400. A route attached to a deleted firewall refuses every visitor. Available in the API, the SDKs, the CLI and the MCP tools. - **KV: Cloudflare Workers KV API.** KV stores now accept Cloudflare's Workers KV REST API at `/compat/cloudflare/client/v4`, so code that uses Cloudflare's SDKs runs against Runtime KV after you change only the base URL and the API token: set `baseURL` to `https:///compat/cloudflare/client/v4`, `apiToken` to a Runtime API key, and use a KV store's ID as the namespace ID. Reading, writing and deleting a value, and listing keys with `prefix`, `limit` and `cursor`, are supported. A request that sets expiration or metadata returns 400 with a message naming the unsupported parameter; bulk operations and namespace management are not yet available and return 404. - **Browsers: capture a page with `POST /v2/browsers/{id}:screenshot`.** Opens `url` in a new tab, waits for its load event, and returns the image as base64 with the page's final URL, title and HTTP status; without `url`, it captures the page the browser shows now. Set `fullPage` to capture the whole page, `format` to `png` or `jpeg`, and `waitMs` (up to 10000) to wait for late scripts. The capture runs inside the browser's own sandbox. Returns `409` (`browser_not_running`) when the browser is not running and `504` (`page_timed_out`) when the page does not load within 30 seconds. Available in every SDK as `browsers.screenshot`. - **Balancers: health checks.** A balancer's `spec.health` sets how each target is checked, with the fields of an AWS ALB target group: `path`, `intervalSeconds`, `timeoutSeconds`, `healthyThreshold` and `unhealthyThreshold`. A target that fails `unhealthyThreshold` consecutive checks receives no requests until it passes `healthyThreshold` consecutive checks. Available in every SDK. - **Sandboxes: upload links replace files in root-owned folders.** A `PUT` link from `POST /v1/sandboxes/{id}/files:link` to a path under `/workspace` whose folder is owned by root (for example, a tarball extracted with `sudo`) returned `404` with `This upload is not open in the sandbox.` The upload now replaces the file atomically, as `files_write` does, and the new file is owned by the sandbox user. When the sandbox user cannot write the folder (outside `/workspace`), the link returns `403 permission_denied` with the folder named, instead of `404`. - **GitHub Actions runners in every SDK.** `/v2/runners` is now in the API reference and the OpenAPI document, and every SDK makes its calls as `runners.settings`, `runners.enable`, `runners.disable`, `runners.jobs` and `runners.job`. `GET /v2/runners/jobs` returns `{"jobs": [...]}`; `GET /v1/actions/jobs` still returns the array. - **Sandboxes: lists and lookups include every sandbox of an organization being moved to Runtime 2.0.** While an organization has sandboxes on both Runtime 1.0 and Runtime 2.0, `GET /v1/sandboxes` returns them all in one paginated list, in the same order and with the same fields as before. `GET`, exec, files and `DELETE` on a sandbox return from wherever it runs, and the console's Sandboxes page shows both. Before, a sandbox created on Runtime 2.0 might be missing from the list, and requests for it returned 404. - **Plans: `includedMicros`, `yearlyOnly` and `trialUsageMicros`.** `GET /v1/plans` returns three new fields on each plan: `includedMicros`, the usage included each month (null when the fee and `bonusMicros` are the plan's usage); `yearlyOnly`, whether the plan is billed yearly only (choosing such a plan monthly returns `409`); and `trialUsageMicros`, the usage a trial includes (`"0"` for none). Every current plan returns `null`, `false` and `"0"`. The TypeScript, Python, Go, Ruby and Java SDKs include the fields. ## 2 November 2026 - **Sandboxes: exec on a newly created sandbox returns as soon as the command exits.** In some regions, `POST /v1/sandboxes/{id}/exec` on a sandbox created at the default size could hang until the client's timeout. The first exec after a create now returns in about 35 ms, and later ones in about 6 ms. - **API keys limited to a compartment reach only that compartment's sandboxes.** On Runtime 2.0, a key limited to one compartment (project) now lists only that compartment's sandboxes, and `GET`, `DELETE`, `:exec`, files, processes and every other sandbox route return 404 for a sandbox in another compartment, the same response as for a sandbox that does not exist. A sandbox created on Runtime 2.0 before this change has no recorded compartment: a compartment's key reaches it only if that key created it. Account-wide keys still reach every sandbox in the account. A create with a `name` that a sandbox the key cannot reach holds returns 409 `name_taken` without naming that sandbox, and `getOrCreate` never returns it. - **CI/CD: a failed job's reason names its first error.** The reason shown for a failed job, in the console and on its GitHub check run, was the last line of its log, which for a build is often a line such as `error: build failed, waiting for other jobs to finish...`. It is now the first error line that names a cause (for example `error[E0308]: mismatched types`), or a `No space left on device` line when the machine ran out of disk. When a long log is shortened, every error line is kept, including `error:` lines without a code, and a compiler's quoted source lines are no longer taken for an error. - **API v2: filter sandboxes and other machines by `phase`.** The lists of sandboxes, browsers, desktops, dev environments, databases, Kubernetes clusters and notebooks (`GET /v2/{resources}`) accept `phase` (`pending`, `running`, `paused`, `stopped` or `failed`) and return only the resources in that phase. For example, `GET /v2/sandboxes?phase=running` returns the running sandboxes. The filter reads an index that is updated in the same write as the status, so a resource appears under its new phase as soon as its status shows it. The TypeScript, Python, Go, Ruby and Java SDKs take `phase` in each of these lists' filtered queries. - **API v2: lists return the API v1 ID of each moved resource with `include=movedFrom`.** Every v2 list (`GET /v2/{resources}`) accepts `include=movedFrom`, which adds `movedFrom` to the page: the ID of each item that moved in from API v1 mapped to the ID it had there. Items created in v2 are not in it. On a metered resource, `include=usage,movedFrom` returns both. Any other `include` value returns `400`. The TypeScript, Python, Go, Ruby and Java SDKs return the page's `movedFrom`, and the CLI takes `--include movedFrom`. - **API v2: lists return each resource's usage to date with `include=usage`.** A list of a metered resource (`GET /v2/{resources}`, for every resource that has `:usage`) accepts `include=usage`, which adds `usage` to the page: each item's ID mapped to what it has used from the month it was created (`since`) to now, with the same figures as `:usage` (`windows`, `heldMs`, `cpuBilledUsec`, `memoryMibMs` and `chargedMicros`), summed over those months. The page is still one request, so a list that shows each resource's cost no longer needs one `:usage` call per item. Any other `include` value returns `400`, as does `include` on a resource that is not metered. The TypeScript, Python, Go, Ruby and Java SDKs take `include` on `list` and return the page's `usage`, and the CLI takes `--include usage`. - **API v2: lists return resources newest first with `order=newest`.** Every v2 list (`GET /v2/{resources}`) accepts `order=newest`, which returns resources by `createdAt`, newest first, and pages with `cursor` as before. `order=oldest`, the default, keeps today's ID order. Labels and filters apply on top of either order. A key limited to the resources it created returns `400` for `order=newest`. The TypeScript, Python, Go, Ruby and Java SDKs take `order` on every `list`, and the CLI takes `--order oldest|newest`. - **Sandboxes: commands run as the sandbox user, and the HTTP proxy works at every size.** On some sandboxes, commands, processes, terminals and file writes ran as root even though the sandbox reported `user: "sandbox"`. They now run as the sandbox user (uid 1000) with its `HOME`, and files written through the files API are owned by that user, as on every other sandbox; `user: "root"` still runs a command through `sudo`. On those sandboxes, every request sent through the built-in proxy (`HTTPS_PROXY`, `http://127.0.0.1:10800`) at a size other than the default returned `502` with `Connection reset by peer`; the proxy now carries them. `/workspace` is owned by the sandbox user, the host name `runtime-guest` resolves (so `sudo` no longer prints `unable to resolve host`), and `PATH`, `NO_PROXY` and `NODE_USE_ENV_PROXY` are set as on every other sandbox. - **Sandboxes: bucket mounts succeed and stay mounted.** `POST /v1/sandboxes/{id}/mounts` checked whether the mount's client was still running by the size of its cgroup's process list, which the kernel always reports as 0. Every mount was therefore taken for a client that had exited: the request returned `422 mount_failed` with "the bucket did not mount", and the client was stopped even when the bucket had mounted. The check now reads the process list itself. A mount returns `200` once the bucket is mounted, `GET /v1/sandboxes/{id}/mounts` reports `mounted: true` for it, and `POST /v1/sandboxes/{id}/mounts:unmount` waits for the client to exit before it returns. - **Sandboxes: sessions work for every sandbox.** `POST /v1/sandboxes/{id}/sessions`, `GET /v1/sandboxes/{id}/sessions` and `POST /v1/sandboxes/{id}/sessions/{sessionId}:revoke` now work for every sandbox, and the `rtsess_` token a session returns can be used with every sandbox. Previously, these requests returned `409 served_elsewhere` for some newly created sandboxes. Sessions behave as before: a token reaches only its own sandbox's commands, files and previews, from the origins it lists, and is rejected with `401` on its next request once it is revoked or expires, or once the API key that created it is revoked. ## 1 November 2026 - **Sandboxes: browser, desktop and MCP on every new sandbox.** `/v1/sandboxes/{id}/browser` (`:start`, `GET`, `:stop`), `/desktop:start`, `/desktop:act`, `/desktop/screenshot`, `/desktop:stop` and `/mcp` (`POST`, `GET`, `DELETE`) previously returned 409 `served_elsewhere` for sandboxes created on the new platform. They now return the same request and response shapes as for any other sandbox. - **Sandboxes: network rules work for every sandbox.** `GET /v1/sandboxes/{id}/network` and `PUT /v1/sandboxes/{id}/network` now work for every sandbox. Previously, these requests returned `409 served_elsewhere` for some newly created sandboxes. The rules have not changed: a change applies to open connections too, so a connection the new rules refuse is closed, and the response reports `enforced: true` once the sandbox's server applies the new version. A sandbox created with `network` rules can now also be created from a snapshot; previously that request returned `501 feature_unavailable` for some newly created sandboxes. - **Sandboxes: a verb the API key does not allow returns 403, and older keys can wake, extend, update, set retention and restart.** A request for a sandbox operation that the API key's grant does not include now returns `403 forbidden`; previously some of these requests returned `409 served_elsewhere`, and retrying them at the address in the error returned the same error again. API keys created before November 1, 2026 that can create sandboxes can now also call `:wake`, `:extend`, `:update`, `:retention`, `:restart`, `:fork`, `:resize` and `:switch-image` on them, as newer keys can. No change to existing keys or code is needed. - **Sandboxes: a fork returns 409 `fork_unsafe` when a copy would repeat the source's random numbers.** A fork (`forkOf`, or `POST /v1/sandboxes/{id}:fork`) copies every process's memory, including the state of each language runtime's own random number generator. The kernel's generator is reseeded in every copy, but Node's UUID cache and OpenSSL generator, Python's `random` and `ssl` generators and Java's `SecureRandom` are reseeded only when the process loads Runtime's reseed shim. A fork whose source runs a Node, Python or Java process that the shim does not reach now returns `409 fork_unsafe`, names each such process, and makes no copy; previously the copies started and could return the same UUIDs, tokens and keys as the source and as each other. While a copy is made, the source's Node, Python and Java processes are paused for a few milliseconds, so no copy runs one of them before it is reseeded. - **Sandboxes: a fork with `count` above 1 makes its copies in parallel.** `POST /v1/sandboxes/{id}:fork` with `{"count": 4}` previously made each copy after the one before it; it now makes them at the same time and records them in one commit, so a fork of 4 copies of a running sandbox returns in 216 ms at the median instead of 340 ms. - **Tailscale: new sandboxes can join a tailnet again.** `POST /v1/sandboxes/{id}/tailscale`, `GET /v1/sandboxes/{id}/tailscale`, `DELETE /v1/sandboxes/{id}/tailscale`, and the `tailscale` field on `POST /v1/sandboxes` and `POST /v1/sandboxes/{id}:update` now work for every sandbox. Previously, these requests returned `501 feature_unavailable` for some newly created sandboxes. The rules have not changed: the auth key is read from your job secret with your key's own permission to reveal it and is never returned or logged. While a sandbox is on a tailnet, forking it or taking a memory snapshot of it returns `409 tailscale_joined`. If a create with `tailscale` cannot join, the sandbox is stopped and the request returns `422 tailscale_failed`. - **API: keep-alive connections stay open across Runtime releases.** When a server's API process was replaced during a release, a request in progress on a keep-alive connection could be answered with `connection: close`, and the client had to open a new connection. The connection now moves to the new process between two requests, after the last response is sent in full, and stays open. Connections whose last request had a body, HTTP/1.0 connections, and connections whose response included `connection: close` are closed after their response instead, as before. - **Jobs: a vouch is no longer cleared by secrets scoped to other sandboxes by ID.** Any change to an egress secret's scope, including a list of other sandboxes' IDs, cleared every vouch in the organization, and the job's runs carried no `job_vouched_by` claim in their identity tokens. Scopes by sandbox ID no longer affect a vouch, except for a run whose own sandbox ID is added to one, which carries no `job_vouched_by` claim. A change to a secret with no scope, a label or App scope, a secret's policy, or the upstream proxy still clears the vouch until an owner or admin vouches again. Vouches that were in effect when this change was released remain in effect. - **Keys: `POST /v1/keys` no longer slows down or returns 502 `keys_unavailable` in accounts with many keys.** In an account holding tens of thousands of keys, creating a key could take 15 seconds or more and return `502 keys_unavailable`, and other requests in the account waited while it ran. Creating a key now reads only the chain of keys that made the calling key and the keys under a maker with a daily limit, and completes in under 100 ms in an account with 50,000 keys. - **Sandboxes use included free usage before credit in every region.** A sandbox's running time is charged to the month's included free usage first, then to credit, in every region where the account runs sandboxes. A sandbox pauses only when both are used up; a wake then returns `402 insufficient_credit` until credit is added. At the start of each month, the new month's included usage applies immediately for accounts whose included usage renews monthly. - **API keys: keys Runtime creates for your builds, CI/CD checks, app versions, agent sessions and Actions runners no longer count toward the limit of 1,000 live keys per member.** Previously, a busy CI/CD repository could fill the limit with its checks' short-lived keys, and `POST /v1/keys` returned `409 limit_reached` to the member who connected it. Those keys are still bounded by the work that uses them and expire with it. - **`spreadFrom` keeps a new sandbox off its peers' servers.** `POST /v1/sandboxes` accepts `spreadFrom` again: 1 to 16 IDs of your own sandboxes. The new sandbox starts on a server that none of them is running on, and stays apart from them each time it is placed again. When no other server has room, the create returns `503 capacity_unavailable` immediately and nothing is made or charged. An ID this key cannot read returns `400 invalid_request`. Snapshot IDs are not accepted in `spreadFrom`, and a create that copies a snapshot returns `501 feature_unavailable` when it sets `spreadFrom`. - **Sandbox creates in a full region fail immediately and name the size.** When no server in the region has room for a new sandbox, `POST /v1/sandboxes` now returns `503 capacity_unavailable` immediately, before any state is written, and the error message states the vCPUs and memory that could not be placed. Retry after the `Retry-After` delay with the same `Idempotency-Key`, or request a smaller sandbox. - **Sandboxes: creates from an existing image no longer return 404 `image_not_found` for some accounts.** `POST /v1/sandboxes` could return `404 image_not_found` (or `snapshot_not_found`, `volume_not_found`) for an image, snapshot or volume that exists in the account. The create now succeeds in the same request, and a retry with the same `Idempotency-Key` returns the same sandbox. - **Sandboxes: creates no longer return 501 or an immediate 503 for some accounts.** `POST /v1/sandboxes` could return `501` (for example `env_unavailable` or `feature_unavailable`) or an immediate `503` for request bodies the API supports. A create rejected before anything was applied now succeeds in the same request, and a retry with the same `Idempotency-Key` returns the same sandbox. A `503 busy` whose create may have been applied is still returned, so a retry never creates a second sandbox. - **Correct clock after a restore.** A sandbox created from a template, forked, or woken from a pause now reads the correct wall-clock time from its first instruction. Previously, such a sandbox could read the time at which its memory snapshot was saved. - **Creates from an image that cannot boot return 404.** `POST /v1/sandboxes` now returns 404 `image_not_found`, naming the image and the reason, when the image is not the organization's or was deleted. Previously it returned 503 `busy`, so retries failed the same way. Nothing is created. - **Requests that carry a secret's placeholder are never sent unreplaced.** If the server running a sandbox has not received secret updates in the last 15 seconds, a request that carries a secret's placeholder, or that a secret's header rules apply to, returns `503` with `Retry-After: 1` and `X-Runtime-Egress: secret-not-current` instead of being sent with the placeholder. Retry the request. - **Files: watches work in sandboxes created from the default image.** `POST /v1/sandboxes/{id}/files/watches` (and `files.watch()` in the SDKs) returned `500 watch_failed` ("The watch exited while starting") in sandboxes created from the default image. A watch now runs in every image, and when one fails to start, the error message includes the exit code and the reason the sandbox reported. - **Sandbox creates move off a failed host immediately.** When the host chosen for a new sandbox has failed and refuses connections, `POST /v1/sandboxes` now places the sandbox on another host immediately, instead of returning `503` until the failed host's lease expires (about 2 seconds). If that host had already started creating the sandbox, the request still waits for it, so each `Idempotency-Key` creates at most one sandbox. - **A Function with no running copy starts one for its next request.** A request to a Function that has scaled to zero waits at the router while a copy is started from the version's saved copy, then is answered; it no longer returns `503 machine_no_capacity` after 10 seconds. `sleepAfterMs` now applies: a copy that requests no longer need keeps running for `sleepAfterMs` after its last request before it stops, so requests a moment apart reach a running copy. - **A crashed process restarts immediately.** When the process of an App, a Function or a container exits, its first restart now happens immediately instead of after 1 second; an App's process killed with `SIGKILL` answers requests again about 60 ms later. A process that exits again still waits 1 second, then 2, 4 and so on up to 60 seconds, and ten exits within 10 minutes still mark it `failed`. - **CI/CD: always run a pull request's own tests.** A split step with `own: true` (`split: { files: "**/*.test.{ts,tsx}", own: true }`) runs the test files a change adds or changes that no other step's `split` names. With such a step in `runtime.yml`, a pull request that adds or changes a test file no step runs (any language: TypeScript and JavaScript, Python, Go, or a Rust crate's files) fails with a `tests not run` job that lists each file and the reason. Configs without an `own` step are unchanged. - **Parallel forks no longer return `503 busy` while a snapshot copy arrives.** When a snapshot's own server was full, creating many sandboxes from that snapshot at once could return `503 busy` ("Cloud is busy; retry"). It happened when a create joined a copy of the snapshot that was arriving on another server. These creates now succeed, and the copy finishes arriving alongside them. - **CI/CD checks every push of the branches you list.** Add `pushes: [{ branch: next, steps: [...] }]` to the production branch's `.runtime/checks.yml` to check each push of a branch that has no pull request of its own, such as a staging branch. Each such check runs only the named steps, appears on GitHub as `runtime @ ` rather than `runtime`, and once started it runs to its own verdict instead of being replaced by a newer push. - **Slack triggers in the v2 Agents API.** `POST /v2/agents/slack/installs` returns a link that adds Runtime's Slack app to a workspace, valid for 15 minutes; `POST /v2/agents/slack/installs:finish` completes it with Slack's `code` and the link's `state`, and accepts only a key of the project that created the link. `GET /v2/agents/slack/teams` lists the project's workspaces and `DELETE /v2/agents/slack/teams/{team}` removes one. `POST /v2/agents/{id}/triggers` with `kind: "slack"` and `config: {team, channel}` adds a trigger: each new message in the channel starts a session, each reply in its thread is sent to the same session, and the session's replies, status, questions and result are posted in that thread. Triggers are read with `GET /v2/agents/{id}/triggers` and `GET /v2/triggers/{id}`, changed with `PATCH /v2/triggers/{id}`, paused and resumed with `POST /v2/triggers/{id}:pause` and `:resume`, and deleted with `DELETE /v2/triggers/{id}`. ## 31 October 2026 - **Sandbox creates no longer wait on an unresponsive host.** When the host chosen for a new sandbox stops responding before it starts creating the sandbox, `POST /v1/sandboxes` places the sandbox on another host, usually within 2 seconds, instead of waiting for that host for up to several minutes. If that host had already started creating the sandbox, the request waits for it, so that each `Idempotency-Key` still creates at most one sandbox. If the cell cannot confirm where the sandbox may be created, the request returns `503`; retry with the same `Idempotency-Key`. - **Stopping a sandbox always completes.** A stop that could not be recorded used to leave the sandbox in `stopping` permanently, and every request on it returned `500 operation_failed`. The stop is now retried automatically every 5 minutes until the sandbox is `stopped`; meanwhile requests on it still return `500 operation_failed` immediately, its files are kept, and nothing is charged. Sandboxes already stuck in `stopping` are stopped by the same retry. - **Previews wait for a server that is still starting.** Within 60 seconds after a sandbox is created or wakes, a preview or TCP port request to a port that is not listening yet now waits up to 10 seconds for the port to open, and is forwarded as soon as it does. Previously it returned `502` with `x-runtime-preview-error: port_closed` immediately, so a health check sent right after create failed until the server started. After 10 seconds the request still returns `502 port_closed`. Requests to a port that is already listening are not delayed. - **Forks of a sandbox in use no longer fail with `sandbox_not_paused`.** If a command or other request woke the source between the fork's pause and the start of its copy, `POST /v1/sandboxes/{id}:fork` returned `409 sandbox_not_paused`. Requests that wake a sandbox automatically now wait until the copy has started, up to one minute, and then proceed as before. - **Postgres read replicas follow the standby.** On a Postgres database with `spec.standby` set to `true`, each read replica now streams its changes from the standby instead of the primary, so no replica is ever ahead of the standby. When the standby is promoted after a host loss, the replicas keep running at the same host names and continue to receive changes from the new primary without being recreated. A database whose host is lost while its `state` is `paused` or `stopped` now also fails over to its standby, then pauses or stops, and its read replicas are removed; previously its standby and read replicas kept running, and were billed, until the database was deleted. - **Exec returns all of its output.** `POST /v1/sandboxes/{id}:exec` now returns every byte a command writes to stdout and stderr, up to 256 MiB of each stream in a JSON response and with no limit in a streamed one. Previously the JSON response kept at most 64 KiB of each stream for a command of 60 seconds or less, and 1 MiB for a longer one. The response adds `stdoutBytes` and `stderrBytes`, the bytes each stream wrote, and `truncated`. The new `maxOutputBytes` parameter limits the response to that many bytes of each stream; a stream it cuts has its truncation flag set to `true`. The TypeScript, Python, Go, Ruby and Java SDKs return the same output and take the same limit (`maxOutputBytes`, `max_output_bytes`, `MaxOutputBytes`). `withruntime sandboxes exec` takes `--max-output-bytes`. `runtime_sandboxes_exec` over MCP returns `stdoutBytes`, `stderrBytes` and `truncated`. Sandboxes started before this change keep the 64 KiB limit until they are stopped and started. - **Forks succeed when the source is woken during the fork.** `POST /v1/sandboxes/{id}:fork` pauses the source while it copies it. If another request woke the source during that time (for example, a command run on it), the fork returned `409 not_paused`, said the source stayed paused, and left its copies running. The fork now returns its copies, and the source keeps running. - **Apps and Functions accept a start command.** `spec.command` on `/v2/apps` and `/v2/functions` sets the process each copy runs, as an argument list, with `PORT` set to `spec.port`. When the process exits, it is restarted automatically, and its last 8 KiB of output is kept in the copy at `~/.runtime/process-.status`. A version that fails its health check includes that output in `status.failure`. When `command` is omitted, the image's start command runs, as before. - **A failed fork deletes the copies it started.** When `POST /v1/sandboxes/{id}:fork` fails after some copies have started, the copies that started are now deleted and no longer appear in `GET /v1/sandboxes`. The error's `details.startedSandboxIds` still names them, and `details.copiesDeleted` is `true`. When the error is a 5xx, a failure on Runtime's side (for example, no capacity for a later copy, a copy that does not reach `running` in time, or the source cannot be woken), none of their running time is billed and `details.copiesBilled` is `false`. When it is a 4xx, caused by the request or the account (a limit or quota, or a copy or the source deleted or changed during the fork), the time they ran is billed. A copy whose create completed after the fork had already failed is deleted too. A copy that could not be deleted at once is named outside `details.deletedSandboxIds`, `details.copiesDeleted` is `false`, and it is deleted within 15 minutes. If the server handling the fork stops before it returns, 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, and its copies are not billed; if your client closes the connection before the fork answers, the copies are deleted and billed for the time they ran. Previously these copies kept running, and were billed, until you stopped them. - **Reads are served while a cell's storage is full.** When a cell's drives are short of space, `GET` requests now succeed instead of returning `503 api_unavailable`, including a project's first request after a front restarts. Requests that change state return `503 capacity_unavailable` with a `Retry-After` header; retry them with the same `Idempotency-Key`. - **Fixed: file shares and streams for sandboxes created with `POST /v2/sandboxes`.** In regions served by the new API, a sandbox created with `spec.fileShares` could fail with "mounts file shares, which this cell's machines do not", and `GET /v2/sandboxes/{id}/streams` returned 404 for any sandbox created through `/v2`. Volumes and file shares are now mounted as they are for `POST /v1/sandboxes`, and exec and pty streams reach the sandbox. One file share can be mounted read-write in several sandboxes on different hosts at once. - **Postgres synchronous standby with automatic failover.** Set `spec.standby` to `true` on a Postgres database to keep a synchronous standby on another rack. A commit is acknowledged only once it is on the standby too. If the database's host is lost, the standby is promoted in its place without a request: the database keeps its ID, host name, port and password, holds every acknowledged commit, and accepts writes again within seconds; a new standby is then made on another rack, and until it runs, commits are acknowledged without a copy on another rack. `status.standby.phase` is `running` once every commit waits for the standby. The standby is billed as a database of the same size that never pauses. - **Password sign-in is faster.** `POST /v2/auth/sessions` with an email and password now checks the password while the sign-in attempt is recorded, instead of after it, and reads the clock alongside the account lookup. On cell 1 the median sign-in dropped from about 47 ms to about 35 ms. Responses, throttling, and the attempt count are unchanged: an attempt is always recorded before the response is sent, and a throttled email is refused before its password is checked. - **`getOrCreate` always returns the one sandbox that holds a name.** Concurrent `POST /v1/sandboxes` requests with `getOrCreate: true` and the same `name` now return the same sandbox, including when that sandbox is paused, stopped with its disk kept, or being moved between regions. Previously, under load, a second sandbox could be created under the same name, and later calls by that name could return either one. A create with a `name` that cannot be placed at that moment, for example while capacity is short, returns 503 `busy` with `Retry-After` and creates nothing; retry it. In the Vercel-compatible TypeScript SDK, `Sandbox.getOrCreate` now creates a missing sandbox with `getOrCreate`, and so does `get_or_create_sandbox` in the Vercel-compatible Python SDK. Concurrent calls for the same name return the same sandbox instead of failing with 409 `name_taken`. `resume: false` still leaves a found sandbox paused. - **Sandbox list pagination returns every page.** `GET /v1/sandboxes` no longer returns `400 invalid_request` (`cursor`) on the second or later page for organizations whose sandboxes run in more than one cell. Each sandbox is returned exactly once, oldest first, and `nextCursor` stays an opaque string to pass back unchanged. `withruntime sandboxes ls` and every SDK's list iterator page through the full list. - **A terminal that cannot open names the missing file.** When a sandbox's image cannot open a terminal for a `pty` process or stream (for example, an image without a devpts at `/dev/pts`), the error now names the file that failed, such as `/dev/ptmx: No such file or directory`, instead of the system error alone. - **`idempotency_key_reused` errors include `details` on every route.** A 422 `idempotency_key_reused` now always returns `details.firstUsedAt` and `details.operation`, naming when the key was first used and for which operation. Some routes previously omitted `details`. - **A fork retried with its `Idempotency-Key` after a 503 now creates its copies.** When `POST /v1/sandboxes/{id}:fork` returns 503 because its copies could not be opened, nothing is created, and sending the same request again with the same `Idempotency-Key` now forks the sandbox, instead of returning `500 operation_failed` ("its copies were withdrawn") on every retry. A fork that succeeded returns its original response to a retry with its key, including after its copies are deleted, and never forks again. - **Pull request checks into non-default branches use at most half of an account's CI/CD machines.** Checks of pull requests whose base is a branch other than the repository's production branch now hold at most half of the machines the account can run at once, combined. The other half stays available for checks of the production branch and of pull requests into it, so those start immediately even when many pull requests into another branch are being checked. A check waiting on this limit shows the reason in its jobs: how many machines checks into other branches hold and the most they may hold. - **Requests that a secret's rules do not allow now return 403 from Runtime.** A request that carries an egress secret's placeholder in a credential header, to a method and path that none of the secret's `rules` allows, is no longer sent to the destination with the placeholder. It returns `403 Forbidden` with `X-Runtime-Egress: secret-rule-not-matched`, and the body names the secret, the method and the path. Previously the destination received the placeholder and typically returned `401` with no explanation. The `sandbox.secret_not_applied` event with reason `rule_not_matched` is still sent. - **A process start whose program is missing returns `422 program_not_found`.** When the program to run, or the interpreter it names (a script's `#!` line or a binary's loader), does not exist in the sandbox (for example, an image without `/usr/bin/env`, through which a command named without a path runs), `POST /v1/sandboxes/{id}/processes` now returns 422 with the code `program_not_found` and the missing path in `details.path`, instead of `502 guest_failed`. A `cwd` that does not exist returns `422 cwd_not_found`. Nothing runs, and a retry returns the same response. - **Interpreter `503 busy` errors include their cause.** When a code interpreter request returns `503` with code `busy`, `error.details.cause` now describes why the request could not be confirmed, for example that the interpreter process exited before it answered. Use it to tell a transient condition that a retry resolves from one that persists. - **Commands start faster in new sandboxes.** `POST /v1/sandboxes/{id}:exec` and process starts no longer wait for the API to look up the organization's egress secret placeholders. The sandbox supplies them from its host before every command, so each command still receives them. A command in a sandbox seconds old previously waited about 1.4 s for that lookup. - **Egress secrets reach every new sandbox.** A sandbox whose start carried no organization in its network rules trusted the egress CA but received none of its organization's secrets: their variables were missing from commands, and requests to the secrets' hosts were sent without credentials. Such a sandbox now gets its secrets like any other. - **SDK methods for workflow runs, steps and events.** The TypeScript, Python, Go, Java and Ruby SDKs add `workflows.start` and `workflows.runs` for a workflow's runs, `jobs.start` for a job's run, `jobRuns.getStep` and `jobRuns.recordStep` for a run's durable steps, and `jobEvents.send` for events. In TypeScript, `withruntime/workflows` exports them, and `runSteps()` authenticates with the run's `RUNTIME_RUN_TOKEN`. `PUT /v2/job-runs/{id}/steps/{name}` with none of `output`, `error`, `sleepUntil` and `event` now records the step with a `null` result instead of returning `400`, so a step that returns nothing is kept and does not run again. - **Run tokens for job and workflow runs.** Each run gets a token in its `RUNTIME_RUN_TOKEN` environment variable. Send it as `Authorization: Bearer` to `GET` and `PUT /v2/job-runs/{id}/steps/{name}` for the run's own steps, so a workflow no longer needs an API key in its `env`. A run token works only for its own run's steps. - **Idempotency keys for job and workflow runs expire after 24 hours.** An `Idempotency-Key` sent to `POST /v2/workflows/{id}/runs` or `POST /v2/jobs/{id}/runs` returns the run it started for 24 hours; after that, the same key starts a new run. - **Durable steps and events for workflows in the v2 API.** `POST /v2/workflows/{id}/runs` starts a workflow run with an `input` (and `POST /v2/jobs/{id}/runs` a job's run), once per `Idempotency-Key`. A run's code records each step's result with `PUT /v2/job-runs/{id}/steps/{name}` and reads it back with `GET`, so a continued run replays it; a step can sleep until a time or wait for an event, sent with `POST /v2/job-events`, and its run continues when the time or event arrives. - **Sandboxes: mount file shares.** `POST /v2/sandboxes` accepts `spec.fileShares`, a list of up to 16 `{name, path, readOnly}` entries; each file share of the project is mounted at its `path` before the sandbox's phase is `running`, and again each time it resumes or starts. The API key needs `file-shares:read` on each share, and `file-shares:write` on each one not mounted `readOnly`: a share the key cannot read, or that does not exist, returns 404; one mounted read-write by a key that may only read it returns 403. `fileShares` is set when the sandbox is created and cannot be changed. A sandbox with file shares cannot be forked, and a sandbox started from a snapshot mounts none. Writes to a share mounted `readOnly` return `EROFS`. Each `path` must be absolute, outside `/proc`, `/sys` and `/dev`, and neither at nor inside another share's path; otherwise the request returns 400. - **Alerts: count rules.** `PUT /v2/alerts/rules/{id}` now accepts rules with `"query": {"kind": "count", "selector": "...", "window_ms": ...}`. A count rule counts the log lines that match a LogQL stream selector and line filters (for example `{service_name="api"} |= "error"`) within the window, and fires when the count crosses its condition. Each line is counted once, as soon as it is accepted, so a count rule's webhook is sent about 10 ms after the line that fires it. - **Sandboxes: call the API without a key.** A sandbox created with `spec.permissions` (for example `["sandboxes:list", "sandboxes:create"]`) can call `https://api.withruntime.com` without an API key. Its host adds a short-lived token for that sandbox to each request and replaces any `Authorization` header the sandbox sends, so no credential is ever stored in the sandbox. Each action must be one the creating key allows; otherwise the create returns 403. A fork gets its own identity with its source's permissions. `permissions` cannot be changed after a sandbox is created. - **Alerts: delete a rule.** `DELETE /v2/alerts/rules/{id}` deletes a rule and returns `{"id": ..., "deleted": true}`, or 404 when no such rule exists. A rule can be deleted while its alerts are firing: evaluation stops within a second, and each firing alert is sent to the rule's webhooks once more with status `resolved`. The limit of 100 rules per organization now also holds when rules are created concurrently; a create past the limit returns 400. - **Logs: faster live tail.** `GET /v2/logs/v1/logs/tail` now delivers a line about 15 ms after it is accepted, down from about 100 ms. A tail now waits for new lines instead of checking for them on an interval. - **CI/CD setup can be a list of layers.** `setup:` in `runtime.yml` now also accepts a list of up to 16 commands, run in order. A push to the production branch keeps each finished layer as a snapshot, keyed to that layer, the layers before it, the machine size and the `cache` files. A later setup starts from the deepest layer that is ready and runs only the layers after it, so a change to the last layer does not run the earlier ones again, and a setup that is stopped or fails partway keeps the layers it finished. Pull request checks start from these snapshots but never write them. A `setup:` of one command works as before. - **Logs: live tail.** `GET /v2/logs/v1/logs/tail?query=` now streams matching log lines as Server-Sent Events, resumable with `Last-Event-ID`. A tail stays open for as long as the API key that opened it is valid. It ends with an `end` event with code `tail_unauthorized` once the key is revoked or no longer grants `logs:read`. If the key cannot be verified for two minutes, the tail ends with code `tail_unverified`; open it again with the ID of its last event. The Loki WebSocket tail (`/v2/logs/loki/api/v1/tail`) still returns 501. - **CI/CD: checks a release waits on get machines first.** When an account's checks are short of machines, a pull request from the repository's own branches into the production branch gets them first, then a push to the production branch, each oldest first, then every other check. While one of these waits, the account's lower checks hand it machines as their running tasks end. A pull request from a fork is ordered with every other check. To skip pull requests into a branch whose commits are tested another way, such as a staging branch, list the branches to check in the production branch's config: `pull_requests: { branches: [main] }`. ## 30 October 2026 - **Sandboxes: `networkRules` in API v2.** `POST /v2/sandboxes` accepts `spec.networkRules` (`internet`, `allow`, `deny`, `connect`), the same rules as the v1 `network` field, enforced on every connection the sandbox opens. A rule that cannot be parsed returns 400 `invalid_network_rule` in v1 and reports the sandbox as `failed` in v2. Changing `networkRules` after a sandbox starts stops it with its disk kept; create a new sandbox to use different rules. Network rules on a copy of a snapshot are not supported yet: v1 returns 501 `feature_unavailable`, and v2 reports the sandbox as `failed`. A fork keeps its source's rules. - **CI/CD checks that run their setup start jobs immediately.** When a check runs its `setup` (its key has no warm snapshot yet), its first job now starts on that set-up machine as soon as the setup ends. Before, the machine was deleted once it was copied, and every job waited for machines started from the copy, which only one server held: when that server was full, a check could wait minutes with no job running. Machines for the rest of its work still start from the copy. - **Usage export shows the carry on each outbound traffic row.** In `GET /v1/usage/export`, each sandbox `egress` row's `outbound` measurements now include `carried`: the fraction of a microdollar the row started from, carried in from earlier outbound traffic in the same month, in bytes times microdollars per decimal GB. The row's charge plus `written_off_micros` equals `floor((carried + billable bytes × rate) / 1,000,000,000)`. Charges are unchanged. - **Agent sessions keep their task in the console after their history is archived.** A session's history moves to cold storage a week after the session ends. The console now still shows what the session was asked to do: the opening words of its first input. Before, the console showed no task for these sessions. Sessions archived before this change still show no task in the console; their full history is still available through the API. - **The MCP server accepts newer protocol versions.** A client that requests an MCP protocol version newer than the server supports, such as `2026-07-28`, now gets a working session at the server's newest version (`2025-11-25`). This applies to `initialize` and to the `MCP-Protocol-Version` header on later requests. Before, those later requests returned `400 Bad Request: Unsupported protocol version`, so clients such as Claude's connectors could not connect. A malformed version, or one older than the server supports, still returns 400. - **A sandbox has all its memory when its create returns.** `POST /v1/sandboxes` now returns only after the sandbox's guest holds all the memory it asked for (`memoryMiB`), so `/proc/meminfo` reports the full amount from the first command. Previously a sandbox could start with part of its memory and receive the rest during its first seconds. A sandbox created with a memory size other than the default can take longer to create, while the memory past its starting size is added. - **A job is no longer failed when its machine is paused by its host.** When a check's machine stops responding because its host paused it, the tasks it held now run on the check's other machines and do not count toward the three losses that end a job with "Lost 3 times, never reported". - **A check that times out while jobs wait for a machine reports "Could not run".** When a check's `timeout:` expires while every job that has not finished is still waiting for a machine that was refused for lack of capacity, those jobs now end with the reason `no machine came for its jobs in N minutes (...); re-run it once there is room`, and the check's GitHub run is titled "Could not run". Before, those jobs were reported as "Ran past its time limit", as if the code under test had hung. - **A sandbox's lease counts from when it first runs.** A paid sandbox that waits in `starting` for its server to have room now has its lease renewed while it waits, so it is no longer stopped with `time_limit` before it ever runs, and its server no longer refuses the start as `invalid_lease` once the wait used up its lease. No compute is charged for the time spent waiting. `diskKept` on a stopped sandbox is now `true` only when `POST /v1/sandboxes/{id}:restart` starts it from its disk: a stopped sandbox whose server was lost now returns `diskKept: false`. - **Evictable sandboxes.** A create or update can now set `evictable: true`. Runtime may then delete the sandbox once it has been paused or stopped for at least 15 minutes and its server needs the disk, the sandbox idle longest first. It is deleted as by `DELETE`; a running sandbox is never deleted for it. Use it for work you can recreate, such as build caches and CI machines. Sandboxes return the setting in `evictable`, and every SDK accepts it on create. ## 29 October 2026 - **A sandbox waiting on its disk is no longer paused as idle.** Idle pause now counts time a sandbox spends blocked on disk I/O as activity, alongside CPU, network traffic and open calls. Before, a sandbox whose processes were blocked on slow disk writes could use no CPU and be paused in the middle of its work. - **Sandboxes: `wakeOnTimers` turns off timer wakes.** A sandbox that pauses because it is idle wakes by itself when a process in it is due on a timer, such as a `sleep 600` loop or an agent that polls every ten minutes. This is on by default for every sandbox, and a timer wake is billed as ordinary use from the moment the sandbox runs. To keep request wakes and turn off timer wakes, set `wakeOnTimers: false` at create or with `POST /v1/sandboxes/{id}:update`; turning it off also cancels a timer wake already planned. The setting is in the TypeScript, Python, Go, Java and Ruby SDKs, the CLI (`--no-wake-on-timers`, `--wake-on-timers on|off`) and the MCP tools, and sandbox responses include it. See [timers wake an idle sandbox](./sandbox-environment#timers-wake-an-idle-sandbox). - **Free machine time in `GET /v1/usage` matches what a create is admitted on.** `trial.availableMs` now counts what running sandboxes hold and the free-usage rules, so a create without credit is refused with `included_usage_used_up` exactly when it is 0. The new `freeUsageHold` field says why an account has no free machine time (`hosting`, `region`, `card`, `withheld`, `no-source` or `unknown`) and what starts it; the refusal's `details.reason` names the same rule, and `npx withruntime usage` shows the message. Before, an account could read free time left while every create was refused. - **A wake that its server has no room for no longer stays in `resuming`.** If the sandbox's server refuses a wake for memory or disk room, the sandbox returns to `paused` within 30 seconds with its memory intact, and the wake operation reports `host_capacity` or `host_memory_pressure` with the reason. The sandbox can be woken again immediately, including by an automatic wake from a command. Previously the sandbox could stay in `resuming` until its lease ended, and every command returned 409 `sandbox_not_ready`. - **Accounts with credit keep their remaining trial hours.** An account that has paid or holds credit uses its remaining free trial hours before credit again, wherever it was opened and whether or not a card is saved. Since earlier today, such an account opened outside the 41 advanced economies, or opened by email with no card saved for auto top-up, was charged credit while it still had trial hours. - **Sandboxes report pressure stall information.** `/proc/pressure/cpu`, `/proc/pressure/memory` and `/proc/pressure/io` are now available in sandboxes started after this release, so tools that read Linux pressure stall information (PSI), such as `systemd-oomd` or a build's own throttling, work inside a sandbox. Sandboxes resumed from a snapshot taken before the release keep their previous kernel settings until they are restarted. - **Sandboxes: a timer wakes a sandbox that paused while idle.** When a sandbox pauses because it is idle, Runtime records when the next timer of a process in it is due (a `sleep`, a wait with a timeout, a `timerfd`, `alarm` or a POSIX timer) and wakes the sandbox about one second before then. Only timers due within 29 minutes of the pause, and pending for the whole idle time, count, so a process that wakes every few seconds does not keep waking it. A sandbox with no pending timer stays paused. A pause you request, a pause at the end of a time limit or of credit, and a sandbox with `autoWake: false` are not woken by timers. See [timers wake an idle sandbox](./sandbox-environment#timers-wake-an-idle-sandbox). - **CI/CD: test only the packages and crates a pull request touches.** Add `pull_requests: { tests: touched }` to the production branch's `runtime.yml`. A pull request into the production branch then runs only the tests of the packages (nearest `package.json`) and crates (nearest `Cargo.toml`) that hold its changed files, plus steps whose `inputs` it changes and steps that read every file. The other tests its change reaches are listed on the `left to main` job, and the production branch's check runs them after the merge. Turn on undo with it so that a commit that breaks the production branch is reverted automatically. A change to a root file other than Markdown, a file in a dot folder, a lockfile or toolchain file, a script a step runs, or the config file runs every test it reaches. The default, `tests: reached`, is unchanged. - **CI/CD: check only pull requests into the branches you list.** Add `pull_requests: { branches: [main] }` to the production branch's `runtime.yml`. A pull request into any other branch ends immediately, uses no machine, and its `runtime` run reads "Not checked" (action required, which never passes a required check). A pull request whose base branch changes is checked again. Without the list, every pull request is checked, as before. - **Free usage for accounts opened by email starts with a card.** An account opened by email, in the regions with free usage, starts its free usage once its owner adds a card under **Auto top-up** on **Usage & billing**. The card is checked by your bank and is not charged unless you turn on auto top-up. Each card starts free usage for one account. Accounts opened with Google and accounts that have bought credit are unchanged. See [free usage by region](./included-usage#free-usage-by-region). - **Stricter checks on new email sign-ups.** A sign-up code is not sent to an address at a domain registered less than 90 days ago, or to a fourth new address at the same domain within a day, unless the domain is a major email provider. Addresses that already have an account are not affected. Sign up with Google, or ask a teammate to invite you. - **One account per inbox when signing up by email.** A new address that is another form of an inbox that already has an account, such as a `+tag` or dotted Gmail address, is not sent a sign-up code. Sign in with the address the account was opened with, or with Google. - **Free machine time outside the listed regions starts with credit.** An account opened from a region outside the 41 countries in [free usage by region](./included-usage#free-usage-by-region) has no free machine time until it buys credit; buying credit provides the full monthly free usage in every region. Accounts opened from the 41 countries are unchanged. - **Free usage depends on the region an account was opened from.** An account that has never bought credit and was opened from one of 41 countries, including the United States, Canada, the United Kingdom, most of Western Europe, Japan and Australia, receives the full free machine time. An account opened from any other region receives 15 minutes of a default sandbox (2 vCPU, 4 GiB): 1 GiB-hour of machine memory and 0.5 vCPU-hours of active CPU. Buying credit provides the full monthly free usage in every region. Free saved copies and disks are the same in every region. See [free usage by region](./included-usage#free-usage-by-region). - **Lower network limits for sandboxes without credit.** A sandbox of an account without credit now uploads at up to 10 Mbit/s (was 50 Mbit/s) and transfers up to 1 GiB a day, inbound and outbound combined (was 5 GiB). Connections to cryptocurrency mining pools are blocked on every port. Downloads have no speed limit, and paid sandboxes are not affected. See [included usage](./included-usage#without-credit). - **A wake of a sandbox moving to another server returns 409 `sandbox_moving`; terminals reattach without errors.** A wake, or a call that wakes a paused sandbox, while the sandbox's paused memory and disk are still being copied to another server now returns `409 sandbox_moving` with `Retry-After`, instead of `503 no_capacity`. The sandbox is unchanged and stays paused. The JavaScript, Python, Go, Java and Ruby SDKs wait and retry it as they do `no_capacity`. A terminal opened with `processId` on a shell that another terminal already typed into no longer closes with `502 guest_failed` ("invalid input offset") on its first keystroke. Its input now starts where the shell's input is. - **Accounts that have never paid are suspended for mining, port scans or internet sweeps.** A sandbox of an account that has never paid that connects to a mining pool, scans ports, or connects to a large number of distinct destinations within a few minutes now has its network cut, and the account is suspended. Accounts that have paid are not affected. See [included usage](./included-usage#without-credit). - **Forks start on another server when the source's server is full.** `POST /v1/sandboxes/{id}:fork` now returns `503 no_capacity` only when no server in the region has room for the copies. When the source's server is full, each copy starts on another server in the region, and the snapshot is copied there first, including large snapshots. The fork's memory limit (a quarter of a server's free memory per fork) now applies to each server the copies can start on, not to the source's server alone. - **A sandbox that stops responding is reported within 5 seconds, and a restart no longer reports a kept disk as deleted.** A running sandbox that stops responding now returns `unreachable`, with `since` and `reason` (`agent_not_answering`, `disk_stalled` or `host_quarantined`), from `GET /v1/sandboxes/{id}`, and the new events `sandbox.unreachable` and `sandbox.reachable` are sent to webhooks. Previously such a sandbox returned `running` with no indication. When the sandbox's server cannot finish writing to its disk, a new command now returns `sandbox_unreachable` (503, retryable) after 5 seconds, with `details.reason` set to `disk_stalled`. Previously the request waited about 30 seconds and returned `guest_busy` (429). The request runs nothing, and commands already running continue. `POST /v1/sandboxes/{id}:restart` on a running sandbox whose server has not confirmed the reboot's stop now returns `sandbox_unreachable` (503, retryable) and keeps the disk. Previously it returned `not_resumable` (409) with `diskKept: false`. A retry with the same `Idempotency-Key` waits for the stop, then starts the sandbox from its disk. A command sent to a stopping sandbox now returns `sandbox_stopped` with "is stopping" instead of "is stopped (ended earlier)". When the server could not pause a sandbox to free disk space, the sandbox keeps running instead of refusing every call. See [troubleshooting](./troubleshooting#a-running-sandbox-refuses-commands). ## 28 October 2026 - **Usage export lists every running-disk charge, including amounts written off.** In `GET /v1/usage/export`, each sandbox `disk_used` row now covers all disk usage since the previous row, and its measurements include `carried`, the fraction of a microdollar carried in from the previous row. A running-disk charge that your balance, cost cap or spending limits could not cover now appears as a row with `chargedMicros` of `0` and its `written_off_micros`. Charges are unchanged. - **Woken sandboxes now end at their time limit.** A sandbox woken with `timeoutSeconds` now pauses or stops (as `onTimeout` specifies) exactly when that time limit ends. Previously, a sandbox woken this way and left to reach its limit could be woken again by Runtime and run, billed, up to one more `timeoutSeconds` past the limit. - **Go, Java and Ruby SDKs: terminals and tunnels stay open when the API restarts.** When the API closes a terminal or tunnel WebSocket with code 1012 (Service Restart), the Go, Java and Ruby SDKs now reconnect with the resume token immediately and continue from the exact byte where they stopped, with nothing lost or sent twice; input written during the hand-off is held and sent after the reconnect. Previously, these SDKs ended the terminal or tunnel. See [Resuming terminals and tunnels after a restart](/docs/api#resuming-terminals-and-tunnels-after-a-restart). - **Terminals, SSH sessions and port forwards stay open when the API restarts.** Previously, an API release closed every open terminal and tunnel. The API now moves each one to the instance that serves next: the server sends `{"type":"restart"}`, then a resume token, and closes the WebSocket with code 1012 (Service Restart). The JavaScript and Python SDKs, the CLI and the console reconnect with the token immediately and continue from the exact byte where they stopped, with nothing lost or sent twice. Clients that speak the WebSocket protocol directly can follow the same steps; see [Resuming terminals and tunnels after a restart](/docs/api#resuming-terminals-and-tunnels-after-a-restart). - **A request whose egress secret cannot be added returns 503 instead of sending the placeholder.** When a sandbox's server has not heard from Runtime's API within the last minute (for example, in the first seconds after a server update), it holds secret values back. Previously, an HTTPS connection opened during that time went to the destination without the secret, and every request on that connection carried the placeholder for as long as the connection stayed open, even after the server heard from the API again. Connections to a secret's host are now always handled by the step that adds the secret. A request that carries the placeholder while its value is held back returns `503 Service Unavailable` with `X-Runtime-Egress: secret-not-current` and `Retry-After: 1`, and the connection closes; the value is added again from the first request after the server hears from the API. Server updates also no longer hold secret values back. - **Commands keep running while a server prepares to pause a sandbox.** When a server paused a busy sandbox on its own (for example, when its disk ran short), it refused every command from the moment it decided to pause, which could be minutes before the sandbox stopped running, and `GET /v1/sandboxes/{id}` still returned `running` meanwhile. Commands are now accepted until the sandbox is paused, the pause is reported immediately, and the next command wakes the sandbox. If the server stops running a sandbox without reporting why, `exec` and file calls now return `503 sandbox_unreachable` (retryable, nothing ran) with a hint to restart it with `POST /v1/sandboxes/{id}:restart`, instead of `409 not_running` for a sandbox whose state is `running`. - **The audit log records your account's backup encryption key.** Each account's backups, snapshot copies and paused sandboxes stored off the server are encrypted with a key of its own. The audit log now records when Runtime creates that key, rotates it (the old key becomes read-only: it still opens existing backups and encrypts no new ones) and destroys an old key. A key is destroyed only after no backup, copy or replica still uses it. Entries are `account.storage_key_created`, `account.storage_key_rotated` and `account.storage_key_destroyed`. - **A command's output stream continues when its server pauses the sandbox.** When a server paused a sandbox on its own (to make room for another sandbox, or at the end of the sandbox's time limit) while a streamed `exec` or a process output read was in progress, the call returned `409 sandbox_paused` and the command's output was lost to the caller. The API now wakes the sandbox and resumes the call, and the command runs to its exit. This also applies to an `exec` sent with an `Idempotency-Key` (every SDK sends one), which is never run twice. A sandbox with `autoWake: false` still returns `409 sandbox_paused`. - **Usage for volumes, backups and snapshots.** `GET /v2/volumes/{id}:usage`, `GET /v2/backups/{id}:usage` and `GET /v2/snapshots/{id}:usage` return a resource's storage usage and charge for one calendar month (UTC), as `GET /v2/sandboxes/{id}:usage` does for a sandbox. The SDKs add the matching `usage` method to each. - **Volumes, backups and snapshots report their measured size.** In the v2 API, `status.usedBytes` on a volume, backup or snapshot is now measured every hour, and the new `status.measuredAt` field returns when it was measured. For a volume, `usedBytes` is the bytes written to it; for a backup or a snapshot, it is the bytes only that backup or snapshot stores, which is what its storage is billed on. Prices and billed quantities are unchanged. - **Sandboxes paused for more than an hour resume in under a second.** A paused sandbox's memory and disk now stay on its server until that server needs the room. Before, they were moved to off-site storage one hour after the pause, and a resume after that took 20 to 50 seconds while they were copied back. A sandbox whose server does need the room still resumes from its off-site copy, with its files and running programs intact. - **Stopping a sandbox keeps writes made just before the stop, even when its agent is busy.** When a sandbox whose disk is kept (or that has a volume attached read-write) is stopped, Runtime asks the guest to write its pending data to disk before the machine ends. If the guest's agent was briefly unable to take that request, for example while the sandbox was out of memory, the request was not retried, and data written in the last few seconds before the stop could be missing from the disk. The request is now retried until the guest takes it, for up to 12 seconds. The stop itself still returns immediately. - **Sites that block by address are paced per outbound address.** Each outbound address shared by more than one account now opens at most 150 new connections an hour to Google, Bing, Amazon and the other [sites that block by address](./egress#sites-that-block-by-address), for all accounts together, and at most 100 an hour for any one account. This includes the addresses kept for scrapers, which had no limit, so one account's traffic could get such an address blocked for every account sent from it. Past a limit, a connection returns `429 Too Many Requests` with `X-Runtime-Egress: rate-limited:guarded-site` and `Retry-After`. Before, one account could open 300 an hour from a shared address. A dedicated outbound address is not limited. - **A create whose sandbox is deleted while it starts returns 410 `deleted`.** If a sandbox is deleted before its own `POST /v1/sandboxes` returns, that create now returns 410 `deleted` with `details.sandboxId`. It previously returned 404 `not_found`. A create with `getOrCreate: true` whose name's holder is deleted while the create waits for it now creates a new sandbox instead of returning 404. See [names](./api#sandboxes). ## 27 October 2026 - **A job's runs in the v2 API and every SDK.** `GET /v2/jobs/{id}/runs` returns a job's runs, newest first and paginated, and `GET /v2/job-runs/{id}` returns one run: its execution, task, attempt, state, exit code and times. The SDKs read them with `jobs.runs(id)` and `jobs.run(id)` (`Runs` and `Run` in Go). - **Connections between your sandboxes are no longer reset in bursts.** Connections by name (`.sandbox.internal`) were refused past 100 new connections a second across the whole account, so a sandbox opening several at once saw some of them reset (about 17% with 8 at once). The limit is now 1,000 a second per account and 500 a second per target sandbox, and a connection over it waits for its turn instead of being refused. A sandbox created moments ago is also reachable within about a second; before, it could be refused with `private-network-off` or `private-network-no-sandbox` for up to 15 seconds. See [networking](./networking). - **OpenTelemetry exports in the v2 API.** The v2 API adds an `otel-exports` resource, with matching methods in the TypeScript, Python, Go, Java and Ruby SDKs. An export sets `endpoint` (an OTLP/HTTP base URL), `signals` (`logs`, `metrics`, `traces`) and `enabled`. `headers` are write-only: they are sent with every push and never returned; `status.headerNames` lists their names. An update that sends `headers` replaces all of them. A project can have at most three exports. The v1 OpenTelemetry export API returns the same responses as before. - **Image builds check variable names in the request schema.** A build request to `POST /v1/images` returns 400 `invalid_request` when a key of `env`, `recipe.env` or `buildArgs` is not a valid variable name (a letter or underscore, then letters, digits or underscores, up to 256 characters). `details.issues` names each such key. Builds already refused these names; the request is now refused before it is read further, and the published OpenAPI document declares the rule as `propertyNames`. - **Webhooks in the v2 API.** The v2 API adds a `webhooks` resource, with matching methods in the TypeScript, Python, Go, Java and Ruby SDKs. A webhook sets `url`, `events`, `enabled` and `description`. Increasing `spec.secretGeneration` by one rotates the signing secret; the previous secret keeps signing for `spec.keepPreviousSeconds`. The secret is returned only in the response to the create request and to each rotation. The v1 Webhooks and Events APIs return the same responses as before. - **Run virtual machines inside a sandbox with `/dev/kvm`.** New sandboxes on current-generation hosts boot a guest kernel with KVM and expose `/dev/kvm` to every user in the sandbox, so QEMU, Firecracker or an Android emulator runs with hardware acceleration instead of emulation. A VM inside a sandbox keeps running through pause and resume, and is carried into snapshots and forks. To check whether a sandbox has it, run `test -c /dev/kvm && echo yes`. Sandboxes created before this change keep their kernel until they are recreated. - **Waking a sandbox keeps the egress CA its running processes trust.** A sandbox started before 26 October 2026 and woken afterwards was given a new egress CA, and processes that were already running (Node.js with `NODE_EXTRA_CA_CERTS`, Bun, Python, Go) failed HTTPS requests that use egress secrets with `SELF_SIGNED_CERT_IN_CHAIN` until they were restarted. A wake now keeps the CA the sandbox already trusts and writes nothing to its trust store. See [security](./security). - **Accepting an invitation adds you only to the account that invited you.** A person whose first sign-in was through an invitation link was also given a separate account in their own name. Opening an invitation now creates nothing; you join the inviting account when you select Join. See [teams](./teams). - **Sandbox usage.** `GET /v2/sandboxes/{id}:usage?month=YYYY-MM` returns what a sandbox used in a calendar month (UTC): its metered periods, milliseconds running, vCPU-microseconds billed, MiB-milliseconds of memory, and the amount charged, in microdollars. Without `month`, it returns the current month. It requires the same permission as reading the sandbox, and is available as `sandboxes.usage(id, month)` in the TypeScript, Python, Go, Java and Ruby SDKs, as `runtime sandboxes usage [--month YYYY-MM]` in the CLI, and as an MCP tool. - **Volumes on v2 Apps, Functions and Containers.** `spec.volumes` lists up to 16 volumes, each with `volume`, `path` and `mode`. A volume is mounted at any absolute `path` outside the system folders (such as `/var/lib/postgresql/data`) before a copy's process starts. With `mode: write` (the default), one copy mounts the volume read-write, so the service runs one copy and `status.copyLimit` is 1, with `status.copyLimitReason` naming the volume. A new version, a rollback or a restart stops the old copy (`SIGTERM`, then `stopGraceSeconds`, default 30) and unmounts the volume before the new copy mounts it. With `mode: readMany`, each copy mounts its own read-only copy-on-write copy of the volume as it was when the version was made, and the service scales as usual. A volume another sandbox mounts read-write fails the version with `volume_attached`; deleting a service never deletes a volume. - **A sandbox at its memory limit stays reachable, and every process killed for memory is recorded.** Linux's out-of-memory killer now never ends Runtime's agent or the sandbox's network relay, so commands keep running after a kill. Each process it ends, inside a command or not, is recorded as the event `sandbox.oom_kill` with its name, process ID and memory. The event is shown on the sandbox's page in the console and sent to webhooks. When the agent does not answer, the API returns `sandbox_unreachable` (503, retryable), and names a recent out-of-memory kill when there was one. When the sandbox cannot read its environment, the API returns `sandbox_environment_unavailable` (503, retryable). Both replace `guest_busy`, which now means only that the sandbox is running as many commands as it can. `POST /v1/sandboxes/{id}:restart` on a running sandbox now reboots it from its disk instead of returning 409 `not_resumable`. See [troubleshooting](./troubleshooting#a-running-sandbox-refuses-commands). - **Relative file paths work in the compatibility layers, and Vercel's `stop()` sends one request.** Paths such as `./hello.txt` or `src/../a.txt` passed to the CodeSandbox, Cloudflare, E2B, Daytona and other compatibility layers now resolve against the layer's working folder (`/project/sandbox` for CodeSandbox), as the vendors' SDKs resolve them. Before, the API returned `400` with `invalid_request` for a path containing `.` or `..`. In `withruntime/vercel`, `sandbox.stop()` no longer reads the sandbox first: it pauses or stops it in one request, saving one round trip per stop. - **Volumes and backups in the v2 API; snapshots no longer pause the sandbox.** The v2 API adds `volumes` and `backups` resources, with matching methods in the TypeScript, Python, Go, Java and Ruby SDKs, the CLI and the MCP server. A volume sets `dailyBackups` and `backupRetentionDays`; a backup names its `volume` and reports `sizeMib` and `expiresAt`. A v2 snapshot now takes `mode` (`memory` or `disk`) and `retentionDays`, and reports `expiresAt`, `vcpus`, `memoryMib` and `diskMib`. Taking a snapshot of a running sandbox no longer pauses it: the sandbox keeps running while the snapshot is made. The v1 Volumes and Snapshots APIs return the same responses as before. - **Connections between your sandboxes are no longer held to upload limits.** Connections by name (`.sandbox.internal`) were limited to 100 Mbit/s per sandbox, and to 20 Mbit/s after the first 2 GiB, as uploads to the internet are. They are now limited only by each server's share of the private network. They do not count toward a sandbox's upload limits or its daily network amount. Each sandbox can still be reached for 100 GiB a day. See [networking](./networking). - **CI/CD jobs stay `queued` until a machine begins them.** A job is now `running` only once one of its machine's tasks starts, and `sandboxIds` lists each sandbox as soon as it begins the job, not only when the job ends. While a job is queued, its `reason` says what it waits for (for example, "Waiting for a machine: the check's machines are starting, or running other jobs' tasks"), the console shows how long it has waited, and the check's `runtime` run on GitHub says how many jobs are waiting and for how long. Previously a job could report `running` for many minutes while its tasks waited behind other jobs' tasks. - **Sandboxes in a compartment reach each other by name.** With the private network on, sandboxes created by keys in the same compartment can now connect to each other at `.sandbox.internal` or `.sandbox.internal`. Before, every connection from a compartment's sandbox was refused with `private-network-off`. Compartments stay walled: a sandbox reaches only sandboxes of its own compartment, or, outside every compartment, only the account's other sandboxes outside every compartment. A name across the wall returns `502` with `private-network-no-sandbox`. - **Requests through a sandbox's web proxy no longer wait 40 ms on responses.** The sandbox's web proxy relay (`127.0.0.1:10800`) and `guest-egress.py forward` now set `TCP_NODELAY` on the program's connection. Previously the kernel held the last segment of any response longer than one segment until the program acknowledged the one before, and most clients delay that acknowledgement by 40 ms. A call to Runtime's API from a sandbox that returns more than about 1.4 KB (creating, pausing or waking a sandbox, reading a file, listing sandboxes) completes about 40 ms sooner, as does any other request made through the proxy. A sandbox uses the new relay from its next boot, or from the wake after its next pause. The transparent relay, used by programs that ignore `HTTPS_PROXY`, gets the same change with the next base image. - **Speed figures are published only from live measurements.** The speed page, the guides, the website, llms.txt and the MCP server's instructions no longer quote the sandbox timings measured in September 2026. The [speed](./speed) page now lists only operations measured by the outside probe each minute whose own time is within 2× of their physical floor, with p50, p95, p99, sample counts and the raw samples. The disk, network and trace overhead measurements moved to [observability](./observability#what-metrics-and-traces-cost). - **Change a key's reach or daily limit over the API.** `PATCH /v1/keys/{id}` (`runtime.keys.update` in the TypeScript SDK, `runtime keys update` in the CLI) changes a key's `reach` (`agent` or `account`) or `dailyLimitMicros` (`null` removes the limit). A key can widen only the keys it made, never past its own reach or daily limit, and returns `403 forbidden` otherwise. It can narrow any key it sees, and it never changes itself. Each change is recorded in the audit log. Previously only the website could change an existing key. - **CI/CD reports a job lost with its machines as "Could not run".** When a job's task is lost on three machines without reporting a result, the job now ends as **Could not run** with the reason (for example, "Lost 3 times, never reported; the last on : no answer for 265 s"). Previously it was reported as "Failed in 0.0 s: exit code 1" and annotated on a file it never tested. A test failure in the same job still fails it. Lost runs are no longer recorded as test failures in flaky-test history. - **Sandboxes reach each other by name on every server.** A sandbox can now connect to another sandbox in its organization at `.sandbox.internal` whichever server runs either one; before, only sandboxes on one server could. Private networks (WireGuard) are reachable from sandboxes on every server too. Connections by name have a daily allowance of 100 GiB for each target sandbox, separate from the private network's 50 GiB. When the private network is briefly unavailable from a server, connections to it return `503` with `private-network-unavailable` and are never sent anywhere else; retry after a minute. - **The speed page is measured every minute, from outside, with the raw samples.** Runtime now times its operations once a minute (volumes every five minutes) through the public API, from a fixed location outside Runtime (a Cloudflare Durable Object in eastern North America), and reports Runtime's own time and the network's part separately, with p50, p95, p99 and sample counts for the last 24 hours. An operation is listed once its own time is within 2× of its physical floor, the least time its work can take on today's hardware. The summary is at `https://status.withruntime.com/speed.json` and each day's samples at `https://status.withruntime.com/speed/samples.jsonl?day=YYYY-MM-DD` ([speed](./speed)). - **Every API response includes a `Server-Timing` header.** The header, `Server-Timing: app;dur=12.3`, gives 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 of each call ([the API](./api#responses-and-errors)). ## 26 October 2026 - **The CLI says when a newer version exists after it rejects an option.** An installed `withruntime` stays at its version until you update it, and `npx withruntime` runs an installed copy before it downloads one, so an older CLI could reject an option the docs list, such as `secrets scope --only-label`, with no sign that it was out of date. Now, when the CLI rejects an option or a command, it checks npm for the latest version, for at most 1.5 seconds, and prints the installed and latest versions with `npm install -g withruntime@latest`. It skips the check in CI and when `RUNTIME_NO_UPDATE_CHECK=1` is set ([the CLI](./cli)). - **Raising a job's timeout no longer requires changing its compute.** Previously, `PATCH /v1/jobs/{id}` with only a larger `timeoutSeconds` returned 400 with "Invalid integer" when the new timeout exceeded the job's `compute.durationSeconds`. Now the update extends `compute.durationSeconds` to the timeout plus 60 seconds of start time, up to 3,600, as a create does. A timeout that already fits leaves it as it is. Refusals of `timeoutSeconds` and `compute.durationSeconds` now name the field and the allowed range. - **HTTPS through egress secrets and preview links keeps working after a sandbox moves to another server.** Previously each server signed with its own certificate authority, so a process started before a wake, a move or a fork onto another server (such as Node with `NODE_EXTRA_CA_CERTS`) failed every request to an egress secret's host with `SELF_SIGNED_CERT_IN_CHAIN` until it restarted. Now each organization has one certificate authority, the same on every server, and running processes keep working. Each sandbox switches to its organization's certificate authority at its next start or wake; a process started before that switch must be restarted once. See [Security](./security). - **Sandboxes created from older images hold as many outbound connections as their limits allow.** Previously a sandbox created from an image or snapshot made before 15 October 2026 kept that image's web proxy relay, which held 64 connections and closed the 65th without a response, even when `GET /v1/sandboxes/{id}/network` reported a higher `limits.connections`. Now every sandbox boots with the current relay, so it holds its full limit and a connection past it returns `503 Service Unavailable` with the limit in `X-Runtime-Egress`. A sandbox that resumes saved memory (a wake, or a copy of a memory snapshot) takes the current relay immediately before its next pause, so it holds its full limit from the wake after that. See [The network](./sandbox-environment#the-network). - **Agents that start from a repository start their sessions.** An agent whose definition sets `from: {"repo": "owner/name"}` starts each session from that repository's CI/CD warm snapshot. Previously every session of such an agent ended immediately with `sandbox_refused` (403 `forbidden`, "a create naming another snapshot"), because the session's key was allowed to create its sandbox only from a snapshot the definition names by id. A session's key may now create its sandbox from any ready warm snapshot of the definition's repository, and from no other snapshot. - **A secret can now be given to the machine as its real value.** Some secrets are not HTTPS headers: a database password, an SSH key, a signing key or a service account's JSON file. Store one with `--exposed` (or `exposed: true` in the API, the MCP tool and the SDKs) and the machines it reaches get the value itself in its variable. It may be any text up to 64 KiB, several lines included. The value is still sealed, and opened only on the server that runs the machine. Code in the machine can read it, so the placeholder kind stays the default and the one to use for HTTPS APIs. For the same reason, it goes only to the sandboxes and Apps you name by id (`--only-sandbox`, `--only-app`), never to every sandbox or by label, and only a key that may run commands in each of them can store it. Which machines get it changes only when you store it again with its value. Every secret now has a version too, one more for each change to it: its value, who gets it, its hosts or its variable. Pass it as `expectedVersion` (`--expected-version`) and a write happens only if no one has changed the secret since; otherwise the answer is 409 `version_conflict`. `GET /v1/egress-secrets/{name}` and `runtime secrets get` show one secret's kind and version, never its value. The website's Secrets page offers both kinds and shows each secret's kind and version. See [a secret the machine needs itself](./security#a-secret-the-machine-needs-itself). - **A failed agent session shows why in the console.** When a session fails because its sandbox could not be created (`sandbox_refused`), the session's page in the console now shows the error the API returned, which previously appeared only in the session's events. - **App deploys report `live` only when the address serves them.** A deploy reaches `live` only after every router serves the new version's route, and an App's `state` is `waiting` until its first version is served, so `waitFor=live`, `runtime apps deploy` and the SDKs return when the address answers from the new version. - **CI/CD: built-in and light-sandbox steps record their result immediately.** An `undoes` or `hot-spots` step, a gate, or a `machine: light` step now shows its result in the console as soon as it finishes, instead of when the check's other jobs end. Previously such a step stayed "running" until then, and a check whose other jobs waited more than 40 minutes for capacity failed with "undoes sent nothing for 40 minutes" although the step had passed. - **`GET http://runtime.internal/v1/identity/token` returns 401 for a request token that is not the sandbox's current one.** It previously returned 403. Read the current token from `http://runtime.internal/v1/environment` and retry. The JavaScript and Python SDKs already do this for both statuses. - **Browsers start with Chromium already running.** A headless browser at the default window size (1280 by 800) is now created from a snapshot of Chromium that is already running, instead of starting Chromium in the new sandbox, which took 1 to 3 seconds. Each browser gets its own CDP secret, its own random state, and an empty profile. A server builds the snapshot the first time a browser of that size is created on it, so that first browser starts as before. Other settings start Chromium as before. - **An outbound connection that cannot reach Runtime's web proxy returns `503 Service Unavailable` with the reason.** Previously the sandbox's relay closed such a connection without a response, which clients reported as `Connection reset by peer`. Now the response carries `X-Runtime-Egress: proxy unreachable ()` and `Retry-After: 1`. A sandbox holds about 1,000 outbound connections at the same time, fewer than `limits.connections` for a sandbox with more than one vCPU, until a later release raises it; past that, new connections return this response. - **A sandbox with no time limit can be funded up to 12 hours ahead.** Set `leaseSeconds` (from its first lease, 1,800 seconds, to 43,200) on create or `:update` (`--lease` in the CLI, `lease_seconds=` in Python, `.leaseSeconds()` in Java, `LeaseSeconds` in Go), and each renewal funds it that far ahead instead of 1,800 seconds. It is renewed when half is left, so it keeps running for at least half of `leaseSeconds` if Runtime's control plane cannot renew it. Each renewal holds that much time's credit at the sandbox's rates; what it does not use is released. A sandbox on the free usage renews by 1,800 seconds until the account has credit. Only an owner's, an admin's or an account-wide key may set it, on a sandbox one of those made. If that key or member loses the right, a sandbox funded past its first lease is paused or stopped at once, as its lease's end would. A sandbox's details include its `leaseSeconds`. - **CI/CD: a cancelled check names the key that cancelled it, and your production branch's check gets machines sooner.** A check cancelled with `POST /v1/ci/checks/{id}:cancel`, `runtime ci cancel` or the MCP tool now shows the reason `asked through Runtime's API with the key "