API v1

Reference

#Sandbox API v1

The one API every mode serves — local (macOS), self-hosted (Linux) and cloud — and every client speaks: the CLI, the agent wrappers, Studio, the SDKs. A behaviour is "the same in all three modes" exactly where the conformance suite (internal/api/conformance) says so.

Status: M2. The core below is implemented by sandboxd against the fake backend. Later additions are listed at the end; they are additions, not changes.

#Transport

  • HTTP/1.1, JSON bodies (Content-Type: application/json), UTF-8.
  • File contents and process stdin are raw bodies (Content-Type: application/octet-stream).
  • Byte fields inside JSON (stdout, stderr, data) are base64, as Go's encoding/json writes []byte.
  • Streams are newline-delimited JSON (application/x-ndjson), one event per line, flushed as produced.
  • All paths are under /v1.

Reaching it:

Mode Listen Auth
Local unix socket, file mode 0600 the socket's permissions; a token is optional
Self-hosted TCP behind TLS Authorization: Bearer <token>, required
Cloud the control plane's endpoint API key as a bearer token

A server listening on TCP refuses to start without a token, loopback included: a loopback port is reachable by every user on the machine. One reachable from other machines needs TLS as well.

Request guard, applied before any handler:

  • Host must be a loopback name or one the server was configured to answer to (DNS-rebinding defence).
  • A request carrying an Origin that is not configured is refused, so a web page cannot drive the API.
  • Bodies are capped at 1 MiB for JSON and 64 MiB for file writes.

#Errors

Every non-2xx response has this body:

{"error": {"code": "refused", "message": "network mode \"open\" is above this server's ceiling (allowlist)"}}
HTTP code Meaning
400 invalid_request malformed, or a field out of range
401 unauthorized missing or wrong token
403 refused well-formed, and refused by policy. Retrying will not help.
403 forbidden_origin the request guard refused the Host or Origin
404 not_found no such sandbox, process or file — among this server's own
409 conflict a name in use, or a sandbox in the wrong state
501 unsupported this endpoint lacks the capability the request needs
503 unavailable this endpoint takes no new sandboxes for now (a cordoned node), or, through a gateway, no node is answering or the one holding the sandbox is down; try again, or elsewhere
500 internal a bug; the message is safe to show

refused and unsupported are different on purpose. The first is a decision: this server will not loosen its policy for anyone. The second is a limitation: another endpoint might be able to do it.

#Capabilities

GET /v1/capabilities

{
  "api_version": "v1",
  "backend": "firecracker",
  "capabilities": {"network_policy_update": true, "suspend": false, "memory_snapshot": false},
  "limits": {"max_cpus": 8, "max_memory_mb": 16384, "max_disk_mb": 102400},
  "network": {"default": {"mode": "allowlist", "allow": ["github.com"]}, "ceiling": "allowlist", "may_allow": ["*"]}
}

A client asks rather than assumes. A request needing a capability the endpoint lacks gets 501 unsupported, never a quietly weaker result.

audit is a property of the server rather than the backend: it is true when the server keeps an event log (below). So are metrics and metrics_export, true where the server can sample sandboxes' usage on its backend.

#Sandboxes

States: pending → running → terminated. suspended is added with the suspend capability; stopped with persistent sandboxes.

POST /v1/sandboxes

{
  "name": "fix-login",
  "image": "sandbox-base",
  "cpus": 2,
  "memory_mb": 2048,
  "disk_mb": 10240,
  "env": {"GOFLAGS": "-mod=mod"},
  "network": {"mode": "allowlist", "allow": ["registry.npmjs.org"], "deny": ["gist.github.com"]}
}

Every field is optional. The responses:

  • 201 with the sandbox.
  • 409 conflict when the name belongs to a sandbox that is not terminated.
  • 400 when a resource is above the server's limits.
  • 400 for a field the server does not know. That includes bind, which mounted a host directory before the repository model was removed: a client that still sends it is told so, rather than given a sandbox without the mount it asked for.
  • 403 refused for any of:
    • a reserved environment variable name (the ones that control the sandbox's own startup, the loader and the shell);
    • a network policy looser than the server permits (below).

The sandbox object:

{
  "id": "sbx_7f3a9c2e1b4d",
  "name": "fix-login",
  "state": "running",
  "image": "sandbox-base",
  "cpus": 2, "memory_mb": 2048, "disk_mb": 10240,
  "env_names": ["GOFLAGS"],
  "network": {"mode": "allowlist", "allow": ["registry.npmjs.org"], "deny": ["gist.github.com"]},
  "created_at": "2026-10-02T09:14:03Z"
}

Environment values are never returned: env_names only. The API is read by dashboards and logs, and a value echoed back is a secret on a screen.

  • GET /v1/sandboxes lists them: {"sandboxes": [...]}, newest first, terminated ones included until the server forgets them. ?label=key=value, repeatable, keeps only sandboxes carrying every label named.

  • GET /v1/sandboxes/{ref} returns one. ref is an id or a name, matched against this server's own sandboxes only and never passed to the backend to resolve.

  • PATCH /v1/sandboxes/{ref} changes a live sandbox; each field left out is left as it is. A request with one bad field changes nothing.

    • network: the egress policy of a running sandbox, under the same rule as create. Needs the network_policy_update capability.
    • name: renames it; "" removes the name. Unique among live sandboxes, as at create (409); through a gateway, among the caller's sandboxes on every node.
    • labels: replaces them whole; {} removes them all. The same rules as at create. A gateway keeps its own gateway.* labels: one sent back unchanged is accepted, any other is refused (400).
    • idle_timeout_secs: between 1 and limits.max_idle_timeout_secs; 0 (never) only where that limit is 0. Unlike at create, 0 is not "the server's default". Counted from the sandbox's last activity.
    • idle_action: "terminate" or "suspend", as at create. A sandbox already asleep keeps waking on use; the action decides the next time it idles.
    • metrics_export: replaces the metrics export whole, under the same rules as at create; {"endpoint": ""} stops it. Header values are never returned, so an update that should keep a header sends it again; one left out is dropped.
    • lifetime_secs: a new lifetime, counted from created_at as at create, so it moves expires_at both ways. At least 1 and at most limits.max_lifetime_secs where that is set; a lifetime already past ends the sandbox at once. Through a gateway only an admin key may lengthen one; any other key may shorten it, or give one to a sandbox without, and is refused a longer one (403 refused).

    name, labels, idle_timeout_secs, idle_action, lifetime_secs and metrics_export are this server's records of the sandbox and change on a running or suspended one with nothing asked of its VM. A terminated sandbox is 409. A VM's vCPUs and memory are fixed while it runs: a new size is a new sandbox, from a disk snapshot where the backend takes them (Studio's Resize does that).

  • DELETE /v1/sandboxes/{ref} terminates the sandbox, stopping its processes and discarding its disk. It is idempotent: deleting a terminated sandbox is 204.

#Network policy

{"mode": "none" | "allowlist" | "open", "allow": ["host", "*.example.com"], "deny": ["host"], "groups"?: ["name"]}
  • none: no egress at all.
  • allowlist: only names in allow are reachable, and a name in deny is refused even if allow matches it. Names are matched by name, not by resolved address, and case-insensitively. *.example.com matches subdomains but not example.com itself; nothing else is a pattern.
  • open: everything except deny. Offered where the backend can (capability egress_open): on Firecracker only when the operator runs sandboxd with --egress-open, and there it is TCP to public addresses on any port plus DNS — never the host, private or link-local addresses, UDP or ICMP (self-hosting.md). deny is by name: a denied name does not resolve, and a connection to port 80 or 443 naming it is refused.

Tighten, never loosen. The server holds a default policy (applied when a request omits network) and a ceiling. A request:

  • may pick any mode at or below the ceiling (none < allowlist < open);
  • may add deny entries freely;
  • may send its own allow list, which replaces the default one: narrowing is sending fewer names. Every name must be either in the server's default list or within its may_allow patterns. ["*"] permits any name; an empty may_allow permits only the default's names. A wildcard is permitted only by * or by a wildcard at least as broad, so *.a.example.com is allowed under *.example.com and *.example.com is not allowed under a.example.com;
  • may not send an allowlist that resolves to nothing. none is how you ask to reach nothing; an empty allowlist would otherwise be the strictest request producing the loosest result.

On the wire, "allow": null (or no allow key) means "the default list", and "allow": [] means "nothing", which is refused. Clients must keep the two distinct. allow makes no sense with none or open, and sending it there is 400. deny is dropped under none, since nothing is reachable.

Names are names: lowercase after normalising, no scheme, port, path or address, and a trailing dot is ignored. Anything else is 400.

A request that breaks any of these is 403 refused, naming the rule. It is never clamped silently.

groups names a gateway's allowlist groups (fleet.md), with mode allowlist only. The gateway adds their hosts to allow before the request reaches a node, so the rules above apply to them as to any host; with allow absent, the groups' hosts are the whole list, not added to the default one. An unknown group, or groups with another mode, is 400. A sandbox's policy as returned never carries groups. sandboxd keeps no groups and answers any 400.

#Processes

POST /v1/sandboxes/{ref}/run runs a command to completion.

{"argv": ["sh", "-c", "go test ./..."], "env": {"CI": "1"}, "cwd": "/sandbox/home/app", "stdin": "<base64>", "timeout_secs": 600}

→ 200 {"exit_code": 0, "stdout": "<base64>", "stderr": "<base64>", "truncated": false, "timed_out": false}

  • Each output stream is kept up to 8 MiB. Past that it is cut, and truncated says so.
  • When the timeout passes, the process is killed and the response comes back with timed_out: true and exit_code: -1.
  • argv is executed directly, never through a shell, unless argv[0] is a shell.
  • A command that does not exist is 400 invalid_request, not an exit code: it failed to start, so there is no process to report on.
  • cwd, if given, is an absolute guest path, checked like a file path. Without it the process starts in the sandbox user's home, /sandbox/home. There is no /workspace: a sandbox has no repository, and code gets in by git clone inside it or through the files API.
  • env here is merged over the sandbox's own, under the same reserved-name rule.

POST /v1/sandboxes/{ref}/processes takes the same body without timeout_secs. It starts the process in the background and returns 201 with the process object:

{"pid": 4, "argv": ["sleep", "30"], "state": "running", "exit_code": null, "started_at": "…"}

pid is the server's handle for the process, not the guest kernel's.

Request Effect
GET /v1/sandboxes/{ref}/processes lists processes, running and exited
GET …/processes/{pid} returns one process
GET …/processes/{pid}/output streams output from the beginning, then follows until exit (format below)
POST …/processes/{pid}/stdin writes the raw body to stdin; ?close=1 then closes it
POST …/processes/{pid}/signal sends {"signal": "TERM"}; one of INT, TERM, KILL, HUP

The output stream is one JSON object per line:

{"stream":"stdout","data":"aGkK"}
{"stream":"stderr","data":"…"}
{"exit_code":0}

#Files

Paths are absolute guest paths. They are never host paths, and the server never resolves them on the host. A relative path, or one containing .. or a NUL, is 400.

Request Effect
GET /v1/sandboxes/{ref}/files?path=/sandbox/home/app/go.mod returns the raw bytes; 404 if absent
PUT /v1/sandboxes/{ref}/files?path=… writes the raw body, creating parent directories; 204
DELETE /v1/sandboxes/{ref}/files?path=… removes the file or empty directory; 204
GET /v1/sandboxes/{ref}/dirs?path=/sandbox/home lists the directory (format below)

A directory listing:

{"entries": [{"name": "go.mod", "type": "file", "size": 412}, {"name": "cmd", "type": "dir", "size": 0}]}

#Attach

GET /v1/sandboxes/{ref}/processes/{pid}/attach with Connection: Upgrade and Upgrade: sbx-stream/1 switches the connection (101) to a two-way stream of frames: a type byte, a big-endian uint32 length, then the payload.

Direction Frame Payload
client → server i stdin bytes
client → server e end of input none (a terminal gets ^D)
client → server s signal INT, TERM, KILL or HUP
client → server r resize rows, cols (uint16 each)
server → client o stdout bytes
server → client E stderr bytes
server → client x exit, last int32 exit code
  • Output is replayed from the start, so a late or second attach sees everything.
  • Disconnecting detaches; it does not stop the process.
  • A process started with "tty": true (background only) runs on a terminal of rows × cols, and its output is one stream.

#Tunnels (capability tunnel)

GET /v1/sandboxes/{ref}/tunnel?port=N with Upgrade: sbx-tunnel/1 switches the connection to the raw bytes of a TCP connection to 127.0.0.1:N inside the guest. Only the guest's loopback is reachable this way, so a tunnel reaches a server in the sandbox and is never a way around its egress policy. Half-closes are passed through.

#Suspend, resume and snapshots

Request Capability Effect
POST …/suspend suspend stops the sandbox, keeping memory, processes and disk. Refused (409) while a process is running, because its stream would be cut.
POST …/resume suspend brings it back as it was. On Firecracker the guest's clock is then set from the host's (a guest agent of protocol 4 or later), since it stood still while the VM did not run.
POST …/resize resize {"cpus"?, "memory_mb"?, "disk_mb"?}: gives a running sandbox a new size, answering the sandbox at it with "restarted": whether it was restarted to get there. A disk that only grows is grown live where the endpoint can (Firecracker, a guest agent of protocol 3 or later): the sandbox and its processes run on, in milliseconds. New CPUs or memory restart it, in a second or two: it keeps its id, network, files and volumes, and its processes end, so that is refused (409) while one runs; so is a disk that cannot be grown live (an older guest agent) while one runs. A field left out (or 0) is kept. Held to the limits a create is (400), with at least 128 MB of memory; a disk only grows (400). Where the new size does not start (the host has not the memory) it is brought back at its old size and the answer is an error; GET it to see the size it has. A sandbox started from a memory snapshot keeps the snapshot's size (501). The guest grows its own filesystem into a larger disk as it boots, so on Firecracker a sandbox started before this was added (an older guest agent) takes new CPUs and memory but keeps the disk space it had. Through a gateway, what it adds counts against the owner's tenant quota (403 refused) and must fit on its node, which it cannot leave (409).
POST …/snapshots memory_snapshot or disk_snapshot captures the sandbox without stopping it: memory, processes and disk with memory_snapshot, its files only with disk_snapshot. Returns {id, sandbox, image, kind, bytes, created_at}; kind is memory or disk.
GET /v1/snapshots, DELETE /v1/snapshots/{id} either list and delete

Creating with "snapshot_id" starts a fork, which diverges from there:

  • from a memory snapshot it begins where the snapshot was, processes running; the snapshot fixes image and resources, so a request asking for different ones is 400;
  • from a disk snapshot it boots afresh with the snapshot's files: nothing that was running comes back. Only the image is fixed; resources are the request's, and default to the original's;
  • a memory snapshot carries the guest's network identity. Firecracker gives each fork a network namespace of its own, so forks of a networked sandbox each get their own network, under the policy the fork asks for (none included). A snapshot of a sandbox without a network gives its forks none, and a fork asking for one is 501; a backend may refuse a fork with a network for the same reason (501).

A disk snapshot is a copy of a live filesystem: what the sandbox writes while it is taken may or may not be in it.

#A snapshot being taken

While a snapshot is taken, by request or by schedule, the sandbox carries its progress, and GET …/{ref} and the list show it:

"snapshotting": {"started_at": "2026-10-06T23:26:10Z", "phase": "capture",
                 "bytes": 958398464, "estimated_bytes": 2260828160}
  • phase is capture (reading the sandbox), then store (keeping it where a fork starts from). bytes is what has been read; estimated_bytes, where the backend has one, is about how much there is. It is an estimate — on macOS the guest's own df — for drawing a bar, and bytes may pass it. "scheduled": true marks one the schedule started.
  • Not every step reports as it goes. On macOS the runtime reads nothing for the first half minute or so (bytes stays 0), and store, about half the time, says nothing until it is done; Firecracker reports neither.
  • The field is gone once the snapshot is made or has failed.
  • One at a time: a snapshot asked for while one is being taken is 409.
  • Deleting the sandbox does not wait for its snapshot, nor stop it. The sandbox is terminated at once and keeps snapshotting until the snapshot ends; the snapshot is then listed like any other, or, if it could not finish, is not there at all, with nothing partial left on the host. On macOS the runtime's export, once begun, reads the whole disk, so a snapshot asked for before the delete comes out complete.
  • A snapshot asked for is not cancelled when its request is: a client that closes the connection part way leaves it to finish, and to be listed.

#Scheduled snapshots

"snapshot_every_secs": 1800, "snapshot_keep": 3 on create, or PUT …/snapshot-schedule with {"every_secs": 1800, "keep": 3} on a running sandbox, has the server snapshot it every so often while it runs and keep the newest few (keep defaults to 1; every_secs 0 stops it). The schedule comes back on the sandbox as snapshot_every_secs and snapshot_keep.

  • The server takes them, so they happen whether or not a client is watching. The interval counts from the end of one snapshot to the start of the next: a disk snapshot that takes a minute never overlaps the following one.
  • They are listed with the rest, marked "scheduled": true. Retention removes only those: a snapshot taken by request is never counted or removed. Each removal is an audit event, snapshot.deleted with reason retention.
  • The server's limits.min_snapshot_every_secs and limits.max_snapshot_keep bound it, and a schedule outside them is 400, not narrowed to one inside.
  • A schedule where the endpoint takes no snapshots is 501, and on a sandbox with volumes 409, before anything is made.
  • Behind a gateway, a scheduled snapshot belongs to whoever owns its sandbox. On macOS one takes about a minute and the size of the sandbox's files on the host's disk.

#Also on create

  • "idle_timeout_secs" terminates the sandbox after that long idle: no process running in it and no request using it. Without it the server's default applies, bounded by limits.max_idle_timeout_secs.
    • A request uses a sandbox when it needs the VM or works with a process: run, starting a process, a file or directory request, a tunnel, a snapshot, a resize, and stdin, signals, output or attach of a process. An update (PATCH) counts too.
    • Reading the server's records does not: GET of the sandbox, the listing, its processes, its events and its metrics. Neither does a request carrying Sandbox-Background: 1, a client's own bookkeeping (a gateway sets it on the reads it makes for itself, and on every request of a key without sandbox:create). It can only make a sandbox idle sooner, so anyone may send it.
  • "idle_action": "suspend" has the idle timeout suspend the sandbox instead (capability suspend; 501 without it, rather than a terminate in its place). It keeps its memory, processes and disk, reports "idle_suspended": true, and its sandbox.suspended event has reason: "idle".
    • The next request that uses it resumes it first, and is then answered as usual; requests arriving together share one resume, whose sandbox.resumed event has reason: "request". A resume that fails is the request's error, and the sandbox stays suspended.
    • A background request is refused (409) and leaves it asleep, as is any request to a sandbox suspended with POST …/suspend, which stays suspended until POST …/resume.
    • "terminate", or leaving it out, terminates as before; a sandbox reports idle_action only when it is "suspend". Its lifetime still ends it, asleep or not.
  • "lifetime_secs" terminates the sandbox that long after it was created, whatever it is doing: running processes and being suspended do not hold it off. Where the server sets limits.max_lifetime_secs, a sandbox that asks for none gets that, and one asking for longer is refused (400). The sandbox reports lifetime_secs and expires_at; a sandbox without a lifetime has neither. The clock is the wall clock from created_at, so time the server was down counts.
  • "snapshot_every_secs" and "snapshot_keep" give it a snapshot schedule (above).
  • "labels": {"agent": "claude", "team": "infra"} is the client's own metadata. Labels decide nothing about the sandbox: they come back with it, filter the listing and are recorded in its audit events. That is how a client says why a sandbox exists without the server knowing what the reason means. Keys are lowercase letters, digits and . _ / -, at most 63 characters. Values are at most 256 bytes of printable UTF-8. There are at most 32 labels. A violation is 400.
  • "registry_login": {"registry": "ghcr.io", "username": "…", "password": "…"} (capability registry_login) pulls the image with that login, for a private one. It is used for that registry only and for this create's pull only; it is never returned, stored or recorded, and what it fetched is not served to a create without it. A server without the capability (the macOS backend) refuses it, 501; a registry that is not a host[:port], an empty user name or one with a colon, or a password with a line break is 400. Through a gateway the field is the gateway's to set, from the organization's registry logins, and a client's is refused, 400.
  • "metrics_export": {"endpoint": "https://…", …} (capability metrics_export) pushes the sandbox's usage to your own collector (below).
  • "gpus": 1 (capability gpu, docs/gpu.md) gives the sandbox that many whole GPUs, passed through to its VM.
    • What the sandbox reports: "gpus": ["0000:01:00.0"], the node's PCI address of each GPU. Its sandbox.created event names them too.
    • One sandbox per GPU: a GPU belongs to one sandbox until that sandbox is terminated. A create for more GPUs than the node has free is 503 unavailable; it is not queued, so try again later.
    • Refused, 400:
      • more than limits.max_gpus;
      • idle_action: "suspend", a snapshot schedule or snapshot_id with it.
    • Refused, 501:
      • gpus on a server without the capability;
      • suspending, snapshotting or resizing such a sandbox;
      • a later idle_action: "suspend" or snapshot schedule on it.
    • The reason: its VMM cannot save a passed-through device's state.

#Metrics (capability metrics)

GET …/metrics is the last hour of what a sandbox used, oldest first, sampled by the server every interval_secs (5) while it runs:

{"interval_secs": 5, "samples": [{"time": "2026-10-06T09:00:10Z", "cpu_percent": 49.8,
  "memory_bytes": 441450496, "memory_limit_bytes": 2147483648,
  "net_rx_bytes": 1200, "net_tx_bytes": 800, "disk_read_bytes": 0, "disk_write_bytes": 4096,
  "processes": 3}]}
  • It is measured on the host — the runtime's counters on macOS, the VMM's process and tap device on Linux — never asked of the guest, which could say anything. A GPU sandbox's guest (below) is the one exception, and is kept apart under that name.

  • The hour is kept in sandboxd's memory, with the sandbox, and nowhere else: not in the state directory and not in the records --keep-sandboxes writes. A sample is 88 bytes, so an hour is about 63 KB a sandbox. It stays readable after the sandbox ends, until sandboxd forgets that sandbox (it keeps the 100 newest terminated ones), and is gone when sandboxd restarts: a sandbox taken back after a restart starts a new hour. For a longer history, have it pushed to your collector (metrics export) or poll this endpoint and keep the samples yourself; sandboxd's Prometheus /metrics has node totals, not each sandbox's usage.

  • cpu_percent is the share of the vCPUs the sandbox was given since the previous sample, 0 to 100; the first sample has nothing to measure from and is 0. The byte counts are totals since the sandbox started; a rate is the difference between two samples.

  • On Linux, memory is the VMM process's resident set: what the guest has touched, which it does not give back by freeing it. processes is 0 where the backend cannot see into the guest.

  • A GPU sandbox (gpu.md) is different in two ways. The host holds all of its memory from boot (passing a GPU through pins it), so its memory_bytes always equals memory_limit_bytes. And the host cannot see into the card. So its samples also carry guest, the guest's own account, asked of its guest agent:

    "guest": {"memory_used_bytes": 1610612736, "memory_total_bytes": 4080218112,
      "gpus": [{"index": 0, "utilization_percent": 37, "memory_used_bytes": 2147483648,
        "memory_total_bytes": 17094934528, "temperature_c": 45, "power_watts": 118.5}]}
    • memory_used_bytes is what the guest's kernel counts as in use (total less available), of memory_total_bytes, the memory the guest sees.
    • Each GPU is read from its driver (nvidia-smi, run as the sandbox user). A figure the driver does not report for the card is absent.
    • It is the guest's word. sandboxd keeps a figure only if it could be true of what the sandbox was given: memory within its limit, no more GPUs than it has, each once, and every GPU figure in range (0–100%, at most 1 TiB of GPU memory, used no more than total, −40–150 °C, 0–5000 W). It drops what fails rather than correcting it. Use it to watch a sandbox, never to bill or limit one.
    • guest is absent from a sample the guest did not answer within four seconds, and from every sample of a sandbox whose guest agent is older than protocol 5.

#Metrics export (capability metrics_export)

A sandbox can have its usage pushed to your own OpenTelemetry collector, or anything else that accepts OTLP over HTTP. Ask for it at create or in an update:

"metrics_export": {
  "endpoint": "https://otlp.example.com",
  "interval_secs": 30,
  "headers": {"Authorization": "Bearer …"}
}

The sandbox then shows it back, header names only, with how it is going:

"metrics_export": {"endpoint": "https://otlp.example.com/v1/metrics", "interval_secs": 30,
  "header_names": ["Authorization"], "last_push_at": "2026-10-16T09:00:30Z"}
  • What is sent. Each push is the sandbox's newest sample (above), as OTLP/HTTP with the JSON encoding, gzipped. The series are sandbox.cpu.utilization (0 to 1), sandbox.memory.usage and sandbox.memory.limit (bytes), sandbox.network.io (bytes, by network.io.direction receive/transmit) and sandbox.disk.io (bytes, by disk.io.direction read/write), both cumulative from the sandbox's start, and sandbox.processes where the backend counts them. The resource says service.name sandbox, sandbox.id, sandbox.name, sandbox.image and each label as sandbox.label.<key>; a gateway's own gateway.* labels are left out.
  • When. Every interval_secs (default 60; at least limits.min_metrics_export_secs, 10 unless the operator says otherwise), while the sandbox runs, and only when there is a sample it has not sent. A suspended sandbox sends nothing. A sample from before the export was set is never sent.
  • Where to. endpoint is an https URL. With no path it gets /v1/metrics. A user, a password or a query in it is refused: a credential goes in headers. The push is made from the host, so the sandbox's own network policy does not decide it, and the host connects only to a public address. That is checked as each push connects, after the name is resolved, so a name that points at the host's loopback, a private network or a metadata address is refused even if it did not when the request was sent. An operator whose collector is on a private network allows it with sandboxd --metrics-export-allow-private, which also allows http to loopback. No redirect is followed.
  • Headers. At most 16, for the collector's credential. A value is never returned, logged, or recorded in an event. sandboxd holds it as it holds environment values: in memory, and in the record --keep-sandboxes writes. Host, Content-Type, Content-Encoding and the like are the push's own and are refused. Through a gateway, "header_secrets": {"Authorization": "otel-key"} fills a header from one of the organization's secrets instead, so the value never sits in the client. It needs the gateway's secrets key (501 without one), and a name that is not a secret is 400. A plain sandboxd has no secrets and refuses it.
  • Failure. A push the collector refuses, or cannot be reached, is in the sandbox's status: last_error (the collector's status, or why the connection failed) and failing_since. Both clear on the next push that works. A failed push is not retried; the next interval sends a newer sample, and the byte counters are totals, so nothing is lost but resolution. A failure is never written to the operator's log.
  • Refused. An endpoint or a header that breaks these rules is 400. A server that does not measure usage refuses the export (501) rather than accepting one it would never push.
  • Samples are kept in the server's memory, with the sandbox: a restarted server starts again from none. Reading them is not activity, so a dashboard watching a sandbox does not keep it from idling out.
  • Behind a gateway, a sandbox's metrics are its owner's, with the read scope.

A volume is a named filesystem that outlives the sandboxes it is mounted in: a package cache, a dataset, a model's weights. What one sandbox writes, the next one to mount it reads.

Request Effect
POST /v1/volumes {"name", "size_mb"} creates an empty volume. 409 if the name exists. size_mb defaults to the server's default disk size and is bounded by max_disk_mb.
GET /v1/volumes {"volumes": [{"name", "size_mb", "created_at", "attached_to"}]}
DELETE /v1/volumes/{name} deletes it and everything on it. 409 while it is mounted.

Mount volumes on create with "volumes": [{"name": "cache", "path": "/sandbox/home/.cache"}, {"name": "models", "path": "/models", "read_only": true}]:

  • A volume has one writer or any number of readers among live sandboxes (409 otherwise): several may mount it read_only together, but none may mount it writable while another holds it, nor read-only while a writer does. A suspended sandbox still holds it. attached_to names one holder (the writer, if there is one).
  • Paths are absolute and plain (letters, digits, . _ - /). They may not be /tmp or a system directory: /proc /sys /dev /run /etc /usr /bin /sbin /lib* /boot. Two mounts may not nest. At most 8.
  • read_only is enforced below the guest: the drive itself is read-only, so a write fails with 409 "read-only file system" even for root.
  • A sandbox with volumes cannot be snapshotted (409): its forks would share them. A sandbox started from a snapshot cannot mount volumes.
  • On Firecracker the guest flushes and closes its volumes cleanly before its VM stops. A VM that crashes keeps only what it had already flushed, as a disk does after a power cut.

#Audit events (capability audit)

GET /v1/sandboxes/{ref}/events returns {"events": [...], "truncated": bool}, oldest first, at most the newest 5000. A terminated sandbox the server has already forgotten is still answered by id, because the log outlives the record; a name is matched against live sandboxes only.

{"time": "2026-10-02T10:17:02Z", "type": "process.exited", "sandbox": "sbx_…", "pid": 1, "exit_code": 4, "duration_ms": 12}
type Carries
sandbox.created name, image, labels, network, env_names, snapshot, volumes, metrics_endpoint; reason: "pool" when it came from a pool
sandbox.network_updated network
sandbox.updated each of name, labels, idle_timeout_secs, idle_action, lifetime_secs that changed, as it is after; metrics_endpoint when the export changed ("none" when it stopped)
sandbox.suspended reason: "idle" when the idle timeout suspended it
sandbox.resumed reason: "request" when a request woke it
sandbox.resized reason: the size before and after, and live or restarted
sandbox.terminated reason: request, idle or lifetime
snapshot.created snapshot, bytes
process.started pid, program, arg_count, args_sha256, cwd, env_names
process.exited pid, exit_code, duration_ms
file.read, file.written, file.removed path, bytes
tunnel.opened port

An environment value is never recorded, and neither is a file's contents or a process's arguments. A process is recorded by its program (argv[0]), how many arguments followed it, and args_sha256: SHA-256 over those arguments, each followed by a NUL byte. An agent's arguments are its prompt, and a command line is where a token gets typed, while the log outlives the sandbox. The hash still lets a known command be matched:

printf '%s\0' -p "fix the tests" | sha256sum    # claude -p "fix the tests"

The program itself is kept, so sh -c "<script>" records sh, 2 arguments and the hash. Labels and paths are recorded as given.

#Images (capability images)

An image is pulled the first time a sandbox asks for it. These let the endpoint's operator install one ahead of that, see what is installed and what uses it, and remove what nothing uses. On a plain sandboxd the operator is whoever holds its token. A gateway answers all three 501 unsupported and does not report the capability: an install fills a node's disk for every tenant on it, and the gateway's own catalog is not done yet.

GET /v1/images lists them, by name:

{"images": [
  {"image": "ghcr.io/amitgb14/sandbox-base:edge", "state": "installed", "digest": "sha256:…",
   "bytes": 2147483648, "installed_at": "2026-10-09T06:00:00Z", "in_use": 2, "default": true},
  {"image": "python:3.13-slim", "state": "installing", "in_use": 0,
   "progress": {"phase": "pulling", "done": 31457280, "total": 52428800}},
  {"image": "ghcr.io/acme/tool:2", "state": "failed", "in_use": 0, "error": "…: manifest unknown"}
]}
  • state is installed, installing (with progress: pulling its blobs, done of total bytes, then building its root disk) or failed (with error). A failed one stays listed until it is installed or removed, or the server restarts.
  • in_use counts the sandboxes running, suspended or starting here from it. An image one runs from is listed as installed even where the backend's own list does not have it.
  • bytes is the image's root disk on Linux, where layers shared with other images are not counted; macOS does not report it. default and pooled mark the server's default image and the images it keeps pools of.

POST /v1/images with {"image": REF} starts installing it and answers 202 with its state: the pull and, on Linux, the build of its root disk, which is most of a first sandbox's wait. It runs in the background, outlives the request, and is followed in the listing. A second request while one runs is the same install. The image is held to the rule a create's is: an image reference (400), and one the policy's images list permits where it has one (403). Installing one already installed checks its tag against the registry.

DELETE /v1/images?image=REF removes it and answers {"image", "freed_bytes"}. On Linux its root disk goes once no other tag of the same image needs it, and then the cached layers no installed image still records. It is refused (409) while a sandbox here starts from it, while it is installing, and for the default or a pooled image. Removing a failed one clears it from the list. 404 when it is not installed.

#Node

GET /v1/node is what a sandboxd reports about itself to a gateway in front of many (NodeStatus): its node name (empty when standalone), version, the capabilities body, capacity and free (cpus, memory_mb, disk_mb, and gpus on a node with any; free is capacity less what every sandbox not terminated has been given, pooled ones included, and never negative), running, pooled by image, the images whose root disks are already built, labels and cordoned.

POST /v1/node/cordon with {"cordoned": true} makes every create answer 503 unavailable until {"cordoned": false}; sandboxes already there are untouched. It returns the NodeStatus. A restart clears it.

A node started with --node-id n17 makes ids of the form sbx_n17_0123456789abcdef.

#Gateway

sandbox-gateway serves this API in front of many nodes (fleet.md). Every endpoint above that is not under /v1/node is served the same way through it; what differs is who the caller is.

Credentials. Every request but GET /v1/health carries an API key the gateway issued, Authorization: Bearer sgk_…. A missing, unknown or revoked key is 401 unauthorized. A key acts as one user, in an optional tenant, with the scopes it was issued:

Scope Covers
sandbox:read GET of a sandbox, the list, processes, output, files, dirs and events; GET /v1/volumes, GET /v1/snapshots. A key with this alone does not keep a sandbox from idling, nor wake one suspended for it: the gateway sends its requests as background.
sandbox:create POST /v1/sandboxes, PATCH, run, processes, stdin, signal, attach, suspend, resume, resize, snapshots, tunnel, file writes and deletes; POST /v1/volumes
sandbox:delete DELETE of a sandbox, a volume or a snapshot
sandbox:ssh the SSH endpoints below, and SSH logins: a login by SSH key or token needs its user to hold an active key with this scope, and an open connection is closed once they no longer do
secrets:write PUT and DELETE of /v1/secrets/{name} and /v1/registry-logins/{registry}
org:create POST /v1/orgs
admin every scope, on every user's sandboxes, and /v1/admin/*; may select any organisation

Attach and a tunnel are GETs that write, so they need sandbox:create. A key without the scope a call needs gets 403 refused ("this API key does not have the … scope") before anything is looked up.

The request guard keeps the node's Origin check (an Origin other than the gateway's own host or a --cors-origin is 403 forbidden_origin, checked before the key), the body caps and the content types. It drops the Host check: the gateway is reached by name on a network, behind a key no page can hold.

Organisation. Any request may carry X-Sandbox-Org: NAME to act in organisation NAME instead of the key's own tenant (Organisations). The gateway checks it once, after the key and before anything else, and the whole request then acts in that tenant. Absent, or naming the key's own tenant (default for the default one), it changes nothing. NAME must be an organisation the key's user is a member of (for an admin key too); otherwise 404 not_found "no such organization", the same whether or not it exists. The header given twice is 400 invalid_request. Scopes never change with it. A plain sandboxd ignores it.

Ownership. A sandbox, volume or snapshot belongs to the user and tenant that created it. A call on one the caller does not own — or that does not exist — is 404 not_found, the same answer either way. A name is looked up among the caller's own sandboxes only. GET /v1/sandboxes lists the caller's own, an admin's too: the super admin does not see or act on other users' sandboxes, volumes, snapshots, jobs or services, and sees the gateway in totals instead (GET /v1/admin/stats, below). The one exception: an admin key may DELETE /v1/sandboxes/{id} any sandbox by its id (not its name), to end one; every other call on it is 404. Volume names are one namespace across the gateway: a name another user holds is 409 conflict.

What the gateway adds or refuses on create:

  • It stamps gateway.owner (the user) and gateway.tenant (when set) on every sandbox. A request that sets any label beginning gateway. is 400 invalid_request, and at most 30 labels may be given.
  • A name is unique per user: one in use among the caller's live sandboxes is 409 conflict.
  • Over the tenant's quota is 403 refused.
  • A sandbox from a snapshot, or mounting volumes, goes to the node that holds them; volumes on different nodes are 409 conflict.

Capabilities (GET /v1/capabilities, any key) are what every answering node offers: a capability only if all have it, the smallest limits, the strictest ceiling.

Errors of its own:

HTTP code When
503 unavailable no node is answering, none is taking new sandboxes, or the node holding this sandbox is not answering
502 internal the node did not answer this request
404 not_found an endpoint the gateway does not serve, including /v1/node

Anything a node answers is relayed as the node sent it.

#Gateway-only endpoints

A plain sandboxd answers each of these 404; that is how a client tells the two apart (GET /v1/whoami).

GET /v1/health — {"status": "ok"}, with no credential, for load balancers.

GET /v1/whoami (any key) — the caller: {"user": "alice", "tenant": "team-a", "key_id": "key_…", "scopes": ["sandbox:read", …], "org": "team-a"}. tenant is the key's own; org is the organisation this request acts in, after X-Sandbox-Org (default for the default tenant). A user an admin gave a policy also gets "policy": {"name": "demo", "max_lifetime_secs": 1800}, and an account's sign-in "email": "bob@example.com".

Accounts (fleet.md). The first three take no key: the invite or the password is the credential. Each refuses a cross-origin browser request, as every route does.

Body Answer
POST /v1/auth/invite {"token"} 200 {"id", "email", "user", "tenant", "scopes", "policy"?, "expires_in_secs"?, "reset"?, "created", "expires"}: what an invite is for. 404 for one used, withdrawn or lapsed.
POST /v1/auth/register {"token", "password", "ttl_secs"?} 200 {"secret", "user", "tenant", "email", "scopes", "expires"}: the account is made (or, for a reset, its password set and its sign-ins ended) and signed in. A password of fewer than 10 or more than 256 characters is 400; an invite not usable is 404; one whose address, user or policy has since become impossible is 409.
POST /v1/auth/login {"email", "password", "ttl_secs"?} 200 as register. secret is an API key for this sign-in alone, lasting ttl_secs (default 12 hours, at most 7 days) and never past the account. 401 unauthorized for a wrong password or an unknown address alike; 403 refused for an account that expired, told only to whoever gave the right password; 429 when the address is locked (five failures in a row lock it for 15 minutes) or failures gateway-wide are too many.
POST /v1/auth/logout (the sign-in's key) 204: the key is forgotten, and what it has open ends. 400 for any other key.
POST /v1/auth/password {"current", "new"} (an account's key) 204: the password is changed, and the account's other sign-ins end. 403 for a wrong current password; 400 for a key that is not an account's.

GET /v1/fleet/images (sandbox:read) — {"images": [{"image": "…", "state": "installed"}]}: the images built on the nodes that take new sandboxes, from each node's last status, so a sandbox from one starts without a pull. Read-only: a gateway does not have the images capability, so GET /v1/images stays 501 unsupported, and installing or removing one is the admin's, below.

GET /v1/ssh (any key) — where the SSH server listens and the host key to pin: {"host": "gateway.example.internal", "port": 2222, "host_keys": ["ssh-ed25519 AAAA…"], "fingerprint": "SHA256:…"}. 404 unsupported when the gateway serves no SSH.

POST /v1/ssh-keys (sandbox:ssh) {"key": "ssh-ed25519 AAAA… comment", "sandbox": "demo"} registers a public key for SSH logins; sandbox, optional, limits it to that sandbox, resolved to its id now. 201 with {"id": "ssh_…", "fingerprint": "SHA256:…", "key": "…", "sandbox": "sbx_…", "created": "…"}. One authorized_keys line, no options (command=, from= …): anything else is 400 invalid_request. Accepted types: ed25519, ECDSA P-256/384/521, the two security-key types, and RSA of 2048 bits or more. A key already registered for the same sandbox, or registered by another user, is 409 conflict.

GET /v1/ssh-keys (sandbox:ssh) — {"keys": [...]}, the caller's own.

DELETE /v1/ssh-keys/{id} (sandbox:ssh) — 204; 404 not_found for a key that is not the caller's. Open SSH connections that logged in with the key are closed.

#Personal API keys

A user's keys for their programs (fleet.md), made with a sign-in or a plain key; a personal key itself is refused all three with 403 refused, as is an SSH token. A key is made in, listed by and acts only in the organization the request acts in (X-Sandbox-Org, or the caller's own); it carries that as org. With the key, a request without X-Sandbox-Org acts there, and one naming another organization is 403 refused. Making one in an organization the caller's user is not a member of is 403 refused, for an admin key too.

POST /v1/keys {"name": "ci", "scopes": ["sandbox:read"], "expires_in_secs": 2592000} — 201 with {"id", "user", "tenant", "scopes", "created", "expires", "policy"?, "personal": true, "name", "org", "secret"}; the secret is shown only here. name is 1 to 64 printable characters. expires_in_secs is required, 3600 to 31622400 (366 days), and may not end the key after the account or key it is made under (400 invalid_request). A scope the caller does not hold, or admin, is 403 refused (an unknown one 400); a 21st live key is 409 conflict.

GET /v1/keys — {"keys": [...]}, the caller's personal keys in the organization the request acts in, as above without secrets, revoked and expired ones included.

DELETE /v1/keys/{id} — 204, and what the key has open is ended as for any revocation; 404 not_found for a key that is not the caller's.

POST /v1/sandboxes/{ref}/ssh-access (sandbox:ssh, and the sandbox must be the caller's) {"ttl_secs": 600} issues a short-lived SSH login for one sandbox. 201 with {"user": "sgt_…", "host": "…", "port": 2222, "expires_at": "…", "command": "ssh -p 2222 sgt_…@…"}. The token is the SSH user name and the whole credential; it is returned once. ttl_secs 0 or absent is 15 minutes; more than 24 hours, or negative, is 400 invalid_request. 404 unsupported when the gateway serves no SSH.

Admin (admin scope; anything else is 403 refused):

Body Answer
POST /v1/admin/keys {"user", "tenant"?, "scopes": [...], "policy"?, "expires_in_secs"?} 201 {"id", "user", "tenant", "scopes", "created", "policy"?, "expires"?, "secret"}; the secret is shown only here. A bad user, tenant or scope, a policy that does not exist, or a negative expires_in_secs is 400 and issues nothing. policy is assigned to the key's user before the key is returned. With expires_in_secs the key stops working that long from now.
GET /v1/admin/keys {"keys": [{"id", "user", "tenant", "scopes", "created", "revoked"?, "policy"?, "expires"?, "email"?, "last_login"?, "suspended"?, "restore_until"?}]}, never a secret; policy is the one the key's user holds, and email marks an account. Sign-ins' keys are not listed: revoking or expiring the account ends them. Users' personal keys are, with "personal": true and their name.
PATCH /v1/admin/keys/{id} {"expires_in_secs"} 200 with the key: it stops working that long from now; 0 is never, negative is now. An expiry now or past ends what the key has open, as DELETE does, but the key can be renewed. 404 unknown; 409 revoked.
POST /v1/admin/keys/{id}/reset {"send_email"?} 201 with an invite (as below) that sets a new password for the account; the old one works until it is used. 400 for a key that is not an account; 409 revoked.
POST /v1/admin/invites {"email", "user", "tenant"?, "scopes": [...], "policy"?, "expires_in_secs"?, "valid_secs"?, "send_email"?} 201 {"id", "email", "user", …, "expires", "token", "emailed"?, "email_error"?}; the token is shown only here, and the link is Studio's address with #invite=TOKEN. With send_email the gateway emails the link to the address through its relay (--smtp-host; 400 without one): emailed says it did, email_error why not, and the invite stands either way. expires_in_secs is the account's lifetime once made (0: for good); valid_secs the link's (default two days, at most seven). The email is lowercased. A bad address, user, tenant or scope, or an unknown policy, is 400; an address or a user with an account is 409. A new invite for an address replaces one pending. With no tenant the invite is a sign-up: its account gets a tenant of its own named after the user (lowercased, each run of . _ @ + a dash, at most 30 characters), 400 when that is no organization name and 409 when it is a tenant in use, a team organization, or one another pending invite names; "tenant": "default" is the default tenant.
GET /v1/admin/invites {"invites": [...], "can_email"?}: those not used and not lapsed, never a token; can_email when the gateway has a mail relay
DELETE /v1/admin/invites/{id} 204; 404 unknown
POST /v1/admin/keys/{id}/suspend 200 with the key, revoked and suspended, and restore_until an hour from now: a revocation from this moment, ending what it has open as DELETE does, that …/restore takes back until then. Its address and user stay its own meanwhile (an invite for either is 409). 409 revoked already, or the key or account the caller acts with; 400 a sign-in's key, or a personal key (revoke it, or suspend its account); 404 unknown. Suspending an account revokes its personal keys for good.
POST /v1/admin/keys/{id}/restore 200 with the key, working again; its sign-ins stay ended and its cancelled jobs stay cancelled. 409 when it is not suspended, or its hour has run out (it is revoked for good).
DELETE /v1/admin/keys/{id} 204; 404 for an unknown id. Before it answers, open requests made with the key (an attach, a followed output stream, a tunnel, a waiting run) are ended (api.revoked in the audit log), open SSH connections whose user no longer holds an active sandbox:ssh key are closed, and running jobs whose owner holds no active key are cancelled ("error": "cancelled: the owner's access was revoked")
GET /v1/admin/orphans {"sandboxes": [{"id", "name"?, "image"?, "state"?, "user", "org", "node", "cpus", "memory_mb", "disk_mb", "recorded", "expires_at"?, "reason"}]}: running sandboxes whose owner can no longer act on them — suspended, revoked or expired, or removed from the organization the sandbox is in (the test that also cancels their jobs) — with why. The only other users' sandboxes an admin is shown; DELETE /v1/sandboxes/{id} ends one. stats.sandboxes.orphaned counts them.
GET /v1/admin/stats {"time", "users": {"total", "accounts", "signed_in_24h", "suspended"}, "orgs", "keys": {"active", "personal"}, "sandboxes": {"running", "created_24h", "created_7d", "created", "history_days"}, "jobs": {"running", "total"}, "services", "allocated": {"cpus", "memory_mb", "disk_mb"}, "nodes": {"total", "healthy", "cordoned", "running", "capacity", "used"}}: the super admin's dashboard. Counts and sums only, never a sandbox's id or an owner. allocated is what running sandboxes were given by the gateway's records; nodes.used is the nodes' own account (capacity minus free): allocations, not live usage. created counts the records the gateway keeps, history_days (--sandbox-history; 0 is all).
GET /v1/admin/nodes {"nodes": [{"name", "endpoint", "healthy", "last_seen", "error"?, "status"?}]}; status is the node's last NodeStatus
POST /v1/admin/nodes {"name", "endpoint", "token_file"?, "ca_file"?, "cert_file"?, "key_file"?} 201 with the node, polled once. A bad name or endpoint is 400; a file outside the gateway's node files directory is 403 refused; a node named in its node file is 409.
DELETE /v1/admin/nodes/{name} 204; 404 unknown; 409 for a node from the node file. Its sandboxes' owners are kept.
POST /v1/admin/nodes/{name}/cordon {"cordoned": true} forwarded to the node's POST /v1/node/cordon; 200 with the node. 404 unknown; 502 when the node does not answer.
GET /v1/admin/images {"images": [...]}: every healthy node's GET /v1/images, one entry per reference in sandboxd's Image shape, with nodes: [{"node", "state"?, "error"?}] saying where it is. installing where any node is installing it (with its progress), failed where one failed (error names the node), else installed; bytes the largest copy, in_use summed, default/pooled if any node's. A node without the images capability is left out.
POST /v1/admin/images {"image"} 202 Image with nodes: the install was asked of every healthy node that is not cordoned, each under its own policy (a node that refuses is in nodes with its error). When every node refuses, the first one's status and message, prefixed with its name; 503 for no node taking new sandboxes.
DELETE /v1/admin/images?image=REF 200 {"image", "freed_bytes"}, summed over every node that had it. Refused 409, removing nothing, while any node has it in use, as its default or pooled image, or installing; 503 when a node does not list its images; 404 when no node has it. A node failing mid-way answers with its own status, naming it and the nodes not removed from.
POST /v1/admin/nodes/{name}/drain {"terminate": false} cordons the node and kept so across its restarts; 200 {"node", "cordoned", "remaining", "terminated": [...], "failed"?: [{"id", "error"}]}. With terminate, its sandboxes are terminated. 503 for a node not answering.
GET /v1/admin/lost {"sandboxes": [{"id", "user", "tenant", "node", "cpus"?, "memory_mb"?, "node_down_since"}]}: sandboxes on nodes not answering for longer than --node-lost-after
GET /v1/admin/audit ?since=RFC3339&limit=N (1–1000, default 100) {"entries": [AuditEntry], "truncated"?}, oldest first; 501 when the gateway keeps no audit log
GET /v1/admin/ssh-keys ?user=U (required) {"keys": [...]}: that user's SSH keys
DELETE /v1/admin/ssh-keys/{id} 204; 404 for an unknown id. Open SSH connections that logged in with the key are closed, as by DELETE /v1/ssh-keys/{id}
GET /v1/admin/orgs {"orgs": [{"name", "created", "created_by", "created_by_tenant"?, "members", "owners", "owned_by"?: [{"user", "tenant"?, "role"}]}]}: every organisation, with who owns it now
GET /v1/admin/policies {"policies": [{"name", "max_lifetime_secs"}]}
PUT /v1/admin/policies/{name} {"name"?, "max_lifetime_secs"} 200 with the policy, created or replaced. A name is 1 to 30 lowercase letters, digits and dashes; a bad one, or a negative lifetime, is 400. A change holds for sandboxes created after it.
DELETE /v1/admin/policies/{name} 204; 404 unknown; 409 while a user holds it
PUT /v1/admin/user-policy {"user", "tenant"?, "policy"} 204: gives the user, named with the tenant of their keys, the policy; "" clears it. An unknown policy is 400.
GET /v1/admin/egress-groups {"groups": [{"name", "description"?, "hosts"}]}; GET /v1/egress-groups answers the same to any key with sandbox:read
PUT /v1/admin/egress-groups/{name} {"name"?, "description"?, "hosts": [...]} 200 with the group, created or replaced, its hosts normalised as an allow list's. A name is 1 to 30 lowercase letters, digits and dashes; a bad name or host, no hosts or more than 100 is 400. A change holds for sandboxes created after it.
DELETE /v1/admin/egress-groups/{name} 204; 404 unknown; 409 while a running job or a service names it

A user policy (fleet.md) holds a user's sandboxes to limits on top of the nodes' own. At create, a sandbox of a user holding one that asks for no lifetime_secs gets max_lifetime_secs (unless a node's own limit is lower, which then applies), and one asking for more is 400. Admin keys hold none.

A node's endpoint is https://host:port, unix:///path, or http:// on loopback only.

#Organisations

Tenants users create and share (organizations.md). Types are in internal/api/types.go (Org, OrgMember, AdminOrg). A member is a user named with the tenant of their own keys; tenant is left out for the default tenant.

Who Body Answer
GET /v1/orgs any key {"orgs": [{"name", "role": "owner" | "member", "created"?, "current"}]}: the key's own tenant first (default when it has none), then every organisation its user is a member of; current marks the one this request acts in
POST /v1/orgs org:create {"name"} 201 Org, the caller its owner. A name is 1–30 of a-z 0-9 -, starting with a letter, not ending with -, with no --, not default or admin: else 400. A name already an organisation or a tenant in use (by a key, sandbox, volume, snapshot, secret, job or service; compared without case) is 409 conflict. Past --max-orgs-per-user organisations made or owned is 403 refused.
GET /v1/orgs/{name}/members a member, or admin {"members": [{"user", "tenant"?, "role", "added"}]}
POST /v1/orgs/{name}/members an owner, or admin {"user", "tenant"?, "role"?} 201 for a new member, 200 for a role change, with the member. tenant defaults to the caller's own (default names the default tenant); role is owner or member (default). Demoting the last owner, or adding a user whose name another member of another tenant already has in it, is 409 conflict.
DELETE /v1/orgs/{name}/members/{user} an owner, or admin ?tenant=T 204; 404 for no such member; 409 for the last owner. Before it answers, the member's open requests, SSH connections and running jobs in the organisation are ended as for a revoked key.

Someone who is not a member gets 404 not_found "no such organization" on every one of these, as for one that does not exist; a member who is not an owner gets 403 refused for a change. A key's own tenant that nobody created as an organisation has no member list (404). The audit log records org.created, org.member_added, org.member_role and org.member_removed (kind org; target the organisation or the member as tenant/user; result the role given).

#Services

A sandbox spec and a count the gateway keeps true (services.md). Types are in internal/api/services_types.go.

Scope Body Answer
POST /v1/services sandbox:create ServiceSpec 201 Service. 409 conflict when the tenant has a service by that name; 400 for a secret the tenant does not have, 501 unsupported for secrets on a gateway without a secrets key.
GET /v1/services sandbox:read {"services": [Service]}, the caller's own (an admin's: every one)
GET /v1/services/{name} sandbox:read Service; 404 for another user's
PUT /v1/services/{name} sandbox:create ServiceSpec 200 Service. A changed image, command, resources, port, health, env or network starts a rollout to a new revision. With ?keep_env=1 the service's env is kept, for a spec read back from a GET (which holds env names only); env in the body is then 400.
POST /v1/services/{name}/scale sandbox:create {"replicas": 5} 200 Service
POST /v1/services/{name}/redeploy sandbox:create 200 Service: a new revision of the spec it has, rolled out one replica at a time, for what changed outside the spec (a secret, an image tag). A secret or allowlist group the spec names and that is gone is 400.
DELETE /v1/services/{name} sandbox:create and sandbox:delete 204; its replicas are terminated

ServiceSpec is {"name", "image"?, "command"?: [...], "replicas", "resources"?: {"cpus", "memory_mb", "disk_mb"}, "port"?, "health"?: {"http": "/path" | "command": [...], "every_secs": 10, "timeout_secs": 5, "failures": 3}, "env"?: {}, "network"?, "placement"?: {"spread": "node"}, "public"?: false, "secrets"?: [...]}; unknown fields are 400. name is a DNS label with no --, at most 100 replicas.

Service is {"spec", "env_names", "owner", "tenant", "revision", "serving", "desired", "ready", "restarts", "rollout"?: {"state": "in_progress" | "done" | "failed", "from", "to", "reason"?}, "error"?, "url"?, "replicas": [{"sandbox", "node", "revision", "state": "starting" | "healthy" | "unhealthy" | "lost", "healthy", "last_check"?, "last_error"?, "restarts", "created_at"}], "created_at", "updated_at"}. spec.env is left out; env_names lists its names. An admin names another tenant's service with ?tenant=T.

Each replica is a sandbox the owner owns, labelled gateway.service and gateway.service.rev; a request that sets either is refused, as for every gateway.* label.

#Jobs, agent runs and secrets

Work the gateway runs after the request has gone, each run in a sandbox of its own made through the same create path (jobs.md). Types are in internal/api/jobs_types.go.

Scope Body Answer
POST /v1/jobs sandbox:create JobSpec 201 Job. A bad spec is 400, a reserved env name 403 refused, a secret the tenant does not have 400, a secrets entry that is not NAME (a secret named as a variable) or VAR=NAME, or whose variable is reserved, set twice or also in env, 400, secrets on a gateway without a secrets key 501.
POST /v1/agent-runs sandbox:create {"agent", "prompt", "name"?, "image"?, "retries"?, "timeout_secs"?, "env"?, "secrets"?, "network"?, "resources"?, "from_snapshot"?, "keep"?, "notify"?} 201 Job of one run
GET /v1/jobs sandbox:read {"jobs": [Job]}, the caller's own, without runs
GET /v1/jobs/{id} sandbox:read Job; 404 for another user's
DELETE /v1/jobs/{id} sandbox:create cancels it; 200 Job once its runs have stopped (a minute at most)
GET /v1/jobs/{id}/runs/{n}/output sandbox:read {"stdout", "stderr", "truncated"} (base64), kept when an attempt ends
GET /v1/jobs/{id}/runs/{n}/files?path=P sandbox:read the file kept from keep.files, application/octet-stream
PUT /v1/secrets/{name} secrets:write {"value": "…"} 204. The name is letters, digits and . _ -, starting with a letter or _, at most 128, not a reserved variable; the value at most 64 KiB, no NUL. 501 without a secrets key.
GET /v1/secrets sandbox:read {"secrets": [{"name", "updated_at"}]}, the tenant's; never a value
DELETE /v1/secrets/{name} secrets:write 204; 404 unknown

#Registry logins

An organization's user name and token for a registry, so its private images can be pulled. The gateway adds the login to every create of the organization — a sandbox, a job's run, a service's replica — whose image is on that registry, and places it on a node with the registry_login capability. No other organization's create gets it, and the token is sealed as a secret's value is, never returned. The names docker.io answers by (docker.io, index.docker.io, registry-1.docker.io) are one registry.

Route Scope Body Answer
PUT /v1/registry-logins/{registry} secrets:write {"username", "password"} 204. The registry is a host[:port]; the user name 1 to 256 bytes without a colon; the password 1 to 8 KiB without a line break. At most 50 per organization (409). 501 without a secrets key.
GET /v1/registry-logins sandbox:read {"logins": [{"registry", "username", "updated_at"}]}, the organization's; never a password
DELETE /v1/registry-logins/{registry} secrets:write 204; 404 unknown

#Agent logins

A user's own login to an agent — the files a subscription login writes in the sandbox user's home (claude: ~/.claude/.credentials.json and ~/.claude.json; codex, gemini: theirs) — kept by the gateway so a new sandbox does not ask for it again. Hosted Studio asks for it on every agent console it starts.

  • One person's. Kept per user and agent, never per organization: a teammate's sandbox never gets it, and no route returns its contents. It is sealed as a secret's value is, bound to its user and agent; without a secrets key the routes answer 501.
  • Copied in when asked. POST …/agent-login copies the caller's kept login into the caller's own sandbox, and from then on the gateway reads it back from there every two minutes (AgentLoginSaveInterval) and once more when the sandbox is deleted through the gateway. It saves what changed — the first login made in the sandbox, a refreshed token — and a sandbox still holding the login it was given does not put it back over a newer one.
  • Read as untrusted. Only the agent's own login files are read, each at most 256 KiB, and ~/.claude.json keeps its account keys only, never an MCP server (a command the agent would start). Files that are missing are "not logged in", never "forget".
Route Scope Body Answer
POST /v1/sandboxes/{ref}/agent-login sandbox:create {"agent"} 200 {"restored": bool}: whether a kept login was copied in. 400 for an agent that keeps no login files, or a credential that is not a user's key.
GET /v1/agent-logins sandbox:read {"logins": [{"agent", "files", "updated_at"}]}, the caller's own; never a file's contents
DELETE /v1/agent-logins/{agent} sandbox:create 204: forgotten, and no longer saved from the caller's sandboxes; 404 when none is kept

A job's notify URL is POSTed {"event": "run.finished" | "job.finished", "job", "name"?, "job_state", "run"?, "state"?, "exit_code"?} — never output or a value. It must be https to a public address, checked again as the gateway connects; a gateway started with --notify-allow-private also posts to private and loopback addresses, and over http to loopback.

Secrets are the tenant's: any key of the tenant may name one in a job or a service, whose sandboxes then hold the value in their environment.

#Later additions

  • Exposed ports on a public hostname (cloud).

Each arrives with its capability flag and its conformance tests.

Edit on GitHubdocs/api/v1.md