Agent-readable docs index: /llms.txt. Full docs in one file: /llms-full.txt. Download /docs.zip to grep all markdown files locally.

HTTP API

Base URL: your deployment's API root. All routes are under /v1. Bodies are JSON unless stated, and unknown JSON fields are refused (invalid_argument). Errors are described in errors.md.

Auth and scopes

Every route except GET /v1/status takes Authorization: Bearer rk_.... A key holds scopes, and a route needs exactly one:
ScopeRoutes
sandboxes:readGET /v1/sandboxes/{id}, GET /v1/usage
sandboxes:writecreate, delete, renew lease
execexec, processes, GET .../events
filesall /files routes
templatesall /v1/templates routes
previewspreview tokens and public ports (and public_ports on create)
sshPOST .../ssh
keyskeys, PUT /v1/account/webhook, PUT /v1/account/command-output
A key can mint only keys with scopes it holds and an expiry no later than its own. Revoking a key revokes every key minted under it.

Sandboxes

States: allocating → running → destroying → terminated.

POST /v1/sandboxes → 202

{"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).
  • Sizes default to one slot (2 vCPUs, 4096 MiB, 8192 MiB) and may be up to four slots: 8 vCPUs, 16384 MiB memory, 32768 MiB disk.
  • 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.
  • Header Idempotency-Key: <string>: a retried create returns the first one's sandbox instead of making another.
The reply is the sandbox, usually still allocating; poll GET until running. A sandbox that ended abnormally is terminated with error_code.
{"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} → 202

Delete is idempotent and answers the sandbox as it stands. The sandbox's slot frees once the host confirms it is gone.

POST /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.

Exec

POST /v1/sandboxes/{id}/exec (scope exec)

{"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:
{"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:
{"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.

Processes (scope exec)

Background commands you can list, write to, signal and reattach to.
Route
POST /v1/sandboxes/{id}/processesSame 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}/stdinRaw 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 (scope files)

All take ?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
A missing path is not_found. A broken download aborts the connection rather than ending cleanly, so a client sees an error, not a short file.

Templates (scope templates)

A template is built from a Dockerfile; each build makes an immutable version, and tags (latest by default) point at versions. Sandboxes boot a template as name or name:tag. Names are lower-case letters, digits, - and _.
Dockerfile subset. 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 .../buildsBuilds, 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.
A failed build carries "error" with the failing step. Builds run on one host, and sandboxes from a template boot on that host.
Shipped host templates: 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 and account (scope 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 (scope previews)

A sandbox port is reachable from outside at 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}/tokensOptional {"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 (scope 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.

Usage and status

  • 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.