/v1. Bodies are
JSON unless stated, and unknown JSON fields are refused (invalid_argument).
Errors are described in errors.md.GET /v1/status takes Authorization: Bearer rk_.... A key
holds scopes, and a route needs exactly one:| Scope | Routes |
sandboxes:read | GET /v1/sandboxes/{id}, GET /v1/usage |
sandboxes:write | create, delete, renew lease |
exec | exec, processes, GET .../events |
files | all /files routes |
templates | all /v1/templates routes |
previews | preview tokens and public ports (and public_ports on create) |
ssh | POST .../ssh |
keys | keys, PUT /v1/account/webhook, PUT /v1/account/command-output |
allocating → running → destroying → terminated.POST /v1/sandboxes → 2021234{"template": "default", "vcpus": 2, "memory_mib": 4096, "disk_mib": 8192, "env": {"K": "v"}, "metadata": {"k": "v"}, "network": {"allow_domains": ["pypi.org", "*.github.com"]}, "lease_seconds": 3600, "public_ports": [3000]}
template is required (invalid_argument if empty): a host template, or
one of yours as name or name:tag (latest when no tag).env applies to every process and is never stored in a template.metadata keys starting runner. are reserved.network.allow_domains: the only hostnames reachable over HTTP(S); omit for
the open baseline.lease_seconds: 60 to 604800, default 3600. See leases.public_ports needs the previews scope as well.Idempotency-Key: <string>: a retried create returns the first one's
sandbox instead of making another.allocating; poll GET until
running. A sandbox that ended abnormally is terminated with error_code.12345{"id": "sbx_...", "state": "running", "generation": 1, "template": "default", "vcpus": 2, "memory_mib": 4096, "disk_mib": 8192, "metadata": {}, "network": {"allow_domains": ["pypi.org"]}, "lease": {"generation": 1, "expires_at": "2026-10-19T14:02:11Z"}, "error_code": "", "created_at": "...", "updated_at": "..."}
error_code appears only when set; network and lease only when present
(lease only while running).GET /v1/sandboxes/{id} → 200 · DELETE /v1/sandboxes/{id} → 202POST /v1/sandboxes/{id}/lease → 200{"seconds": 7200} → {"generation": 3, "expires_at": "..."}. Only an
explicit renewal extends a lease. Details, warnings and the outage grace are in
../api/leases.md.GET /v1/sandboxes/{id}/events → 200 (scope exec){"events": [{"at": "...", "kind": "running", ...}]}. Kinds are the states a
sandbox entered (allocating, running, destroying, terminated, each with
generation and, if any, error_code and reason), plus exec (with cmd,
exit_code, reason, lost, and stdout/stderr only if the account opted
in), lease_warning, port_public and port_private (with port). History is
kept 30 days after the sandbox terminates; stored command output 7 days.POST /v1/sandboxes/{id}/exec (scope exec)12{"cmd": "python3", "args": ["-c", "print(1)"], "cwd": "/workspace", "env": {"K": "v"}, "user": "user", "timeout_ms": 30000, "stream": false}
cmd is a program, not a shell line. For pipes use "cmd": "sh", "args": ["-c", "a | b"]. The sandbox must be running: allocating is
sandbox_not_ready, and a gone sandbox is not_found or the code that ended it.stream: false → 200 with the whole result:1{"exit_code": 0, "stdout": "1\n", "stderr": "", "reason": "exited", "lost": 0}
reason is exited, signaled (exit_code is 128+signal), timeout or
oom_killed. A timeout or OOM answers 504 / 422 with
{"error": {...}, "result": {...}}. A non-zero exit_code is still 200.
lost counts output bytes dropped because the output buffer overflowed. Output
is text: non-UTF-8 bytes come back replaced, so move binary data through files.stream: true → 200 application/x-ndjson, one event per line:123{"type":"start","pid":"p-1"} {"type":"output","stream":"stdout","offset":0,"data":"hi\n"} {"type":"exit","exit_code":0,"reason":"exited"}
exit_code is omitted when 0. The stream ends with exit, or with error
(timeout, OOM, or a host failure) carrying "error": {code, message, remedy}.
A stream that ends with neither was cut off: treat it as unavailable.exec)| Route | |
POST /v1/sandboxes/{id}/processes | Same body as exec (no stream). → 201 {"pid": "p-1"} |
GET .../processes | → 200, a list of {pid, cmd, args, running, exit_code, reason, signal, system, started_at, exited_at} |
GET .../processes/{pid} | One process. ?wait=true answers once it has exited. |
GET .../processes/{pid}/output?from=<byte offset> | NDJSON stream as above, replayed from the offset (default 0). |
POST .../processes/{pid}/stdin | Raw request body is written to stdin. ?close=true then closes it. → 204 |
POST .../processes/{pid}/signal | {"signal": "TERM"} → 204. One of CONT HUP INT KILL QUIT STOP TERM USR1 USR2. |
files)?user= (or "user" in a JSON body) to act as that sandbox user.
Paths are absolute paths inside the sandbox.| Route | |
GET /v1/sandboxes/{id}/files?path= | Raw bytes (application/octet-stream), streamed. |
PUT /v1/sandboxes/{id}/files?path=&mode= | Raw request body is the file; atomic replace. mode is octal such as 0644. → 204 |
DELETE /v1/sandboxes/{id}/files?path=&recursive=true | → 204 |
GET .../files/stat?path= | {name, path, size, mode, mod_time, is_dir} |
GET .../files/list?path= | A list of the same objects. |
POST .../files/mkdir | {"path": "/x", "mode": "0755"} → 204 |
POST .../files/move | {"from": "/a", "to": "/b"} → 204 |
not_found. A broken download aborts the connection rather
than ending cleanly, so a client sees an error, not a short file.templates)latest by default) point at versions. Sandboxes boot a template as
name or name:tag. Names are lower-case letters, digits, - and _.FROM image@sha256:<digest> is required and must be
pinned by digest (a tag is refused); one stage only. Supported: RUN, ENV,
WORKDIR, USER, CMD/ENTRYPOINT (these become the start command, which
then needs ready). LABEL, EXPOSE, MAINTAINER are ignored. COPY is
refused (no build context yet), and so is every other instruction. Every
mistake is invalid_argument naming the line.| Route | |
GET /v1/templates | [{"name": "..."}] your templates |
POST /v1/templates/{name}/builds | {"dockerfile": "...", "tag": "latest", "ready": "cmd", "disk_mib": 2048, "args": {}} → 202, a build. disk_mib defaults to 2048, at most 8192. |
GET .../builds | Builds, newest first. |
GET .../builds/{build} | One build: {id, template, status, version, phase, steps, error, created_at, finished_at}. status is running, succeeded, failed, cancelled or expired. |
GET .../builds/{build}/logs?from= | NDJSON: {"type":"log","offset","data"} lines, then {"type":"end", ...the build} (or error). from is a byte offset. |
POST .../builds/{build}/cancel | → 202 |
GET .../versions | {"versions": [{id, build, size, sha256, spec, created_at}], "tags": {"latest": "<version id>"}} |
PUT .../tags/{tag} | {"version": "<version id>"} → 204. Pointing latest at an older version is a rollback. |
"error" with the failing step. Builds run on one host,
and sandboxes from a template boot on that host.default (Debian with Python, Node, git, curl, jq) and
coding-agents (Claude Code, Codex and OpenCode preinstalled; provider keys go
in env at create time). There is no default template: the CLI and MCP
server add none either.keys)| Route | |
POST /v1/keys | {"scopes": ["exec"], "expires_at": "RFC3339"} → 201 with the new key once in "key". |
GET /v1/keys | {"keys": [{id, scopes, expires_at, revoked_at, created_at}]} (never the secret) |
DELETE /v1/keys/{id} | Revokes it and every key minted under it. |
PUT /v1/account/webhook | {"url": "https://..."} → {"url", "secret"} (secret shown once; empty url removes it). Lease-warning webhooks: see leases. |
PUT /v1/account/command-output | {"store": true} → opts in to keeping exec stdout/stderr in the events log; false discards what was stored. |
previews)https://<port>-<sandbox id without sbx_>.<preview domain>/ with a preview
token, or with none once the port is public. Previews exist only where the
deployment enabled them; otherwise these routes answer unsupported (501).| Route | |
POST /v1/sandboxes/{id}/ports/{port}/tokens | Optional {"ttl_seconds": 3600} (1 to 86400). → 201 {port, url, token, expires_at}. Send the token as header X-Runner-Preview-Token or query runner_preview_token. It is stripped before the request reaches your app. |
PUT /v1/sandboxes/{id}/ports/{port} | {"public": true} → {port, public, url} |
ssh)POST /v1/sandboxes/{id}/ssh with {"public_key": "ssh-ed25519 ...", "ttl_seconds": 3600}
(1 to 86400) → 201 {"user": "sbx_...", "fingerprint": "SHA256:...", "expires_at": "..."}.
Then ssh -p <gateway port> <user>@<gateway host> with that key. Public keys
only; no password, no forwarding, no PTY (a shell runs over pipes). The grant
dies with the API key behind it. runner ssh does all of this.GET /v1/usage (scope sandboxes:read) → {"slots", "used", "peak", "peak_at"}:
the account's slot entitlement and how many are held.GET /v1/status (no key) → {generated_at, operational, windows, incidents};
GET /status is the same as a page.