Documentation: all sections

Node agent contract

This is the wire contract the gateway uses today to hand a unit of work to a sandbox backend. It is not a proposal or a sketch: it is the shape the gateway already speaks behind sandbox compute, and it is small on purpose.

We publish it for two reasons. First, so anyone weighing up whether to run a node later can read exactly what a node has to implement, before talking to us rather than after. Second, because the interface was published before anything connected to it. An interface designed in private, against no implementation, tends to be wrong in ways nobody finds until it is expensive. It is now exercised in production.

Enrolment is not open to third parties. The registry, enrolment endpoint, heartbeat, and scheduler exist and are live, but every enrolled node is a Saxeo-operated house node. Zero third-party machines serve Saxeo traffic. The operator programme opens later. See /operators for where that stands.

The one call

POST {base}/run
Content-Type: application/json
Authorization: Bearer <key>     (only when a key is configured)

{base} is the node's base URL. /run is the only path the gateway calls to execute work. Nodes never poll for jobs (push transport: scheduling authority stays at the gateway). If a key was supplied for the node, it is sent as a bearer token on every request; if not, no Authorization header is sent at all.

A node reaches the gateway's rotation one of two ways:

An enrolled node's lifecycle is visible on the public GET /v1/nodes listing, where enrolled nodes report synthetic: false. Status is computed from the heartbeat, never stored optimism: enrolled (never heartbeated) → online (fresh beat) → stale (missed beats), plus draining (finishes in-flight work, takes nothing new) and disabled (takes nothing), set by the owning account via POST /v1/nodes/:id/draining and POST /v1/nodes/:id/disabled.

Request

{
  "image": "python:3.12-slim",
  "argv": ["python3", "-"],
  "stdin": "print(sum(range(10)))",
  "timeout_secs": 30,
  "vcpu": 1,
  "mem_mb": 512,
  "network": false,
  "env": null
}
imageTypestringMeaningContainer image to run the work in. Resolved by the gateway from the caller's language, or taken verbatim if the caller named an image.
argvTypearray of stringsMeaningThe interpreter invocation, executed inside the image, e.g. ["python3", "-"], ["node", "-"], ["bash"]. This is not the caller's code.
stdinTypestringMeaningThe submitted code, to be written to the process's standard input. Never empty: the gateway rejects an empty payload before it dispatches.
timeout_secsTypeintegerMeaningWall-clock budget for the run. Already clamped to the deployment maximum, so it needs no second-guessing.
vcpuTypeintegerMeaningvCPUs the run gets. Already clamped.
mem_mbTypeintegerMeaningMemory in megabytes. Already clamped.
networkTypebooleanMeaningfalse means no egress: the process must not be able to reach the network. false unless the caller explicitly asked for network.
envTypeobject of string to string, or nullMeaningEnvironment variables for the process. null when the caller sent none.

Three things worth being blunt about:

Response

A 2xx with this body:

{
  "stdout": "45\n",
  "stderr": "",
  "exit_code": 0,
  "timed_out": false
}
stdoutTypestringMeaningStandard output of the process. Missing or non-string is read as "".
stderrTypestringMeaningStandard error of the process. Missing or non-string is read as "".
exit_codeTypeinteger or nullMeaningProcess exit status.
timed_outTypebooleanMeaningtrue if the node stopped the run because it hit timeout_secs. Missing is read as false.

Any other field in the body is ignored, so a node is free to return more.

The gateway turns those four values into the run's recorded status:

trueexit_codeanythingRecorded astimeouterror_classtimeout
falseexit_codeexactly 0Recorded assucceedederror_classnone
falseexit_codeanything else, or absentRecorded asfailederror_classnonzero_exit

timed_out wins over exit_code. And note the third row: a response that omits exit_code entirely is recorded as a failed run, not a successful one: silence is not success.

Output size

The gateway truncates each stream at 256 KiB, cutting on a UTF-8 character boundary, and flags the caller's response as truncated. A node may send more than that; it will be cut. Nothing is gained by sending it.

Failing, and who pays

This is the part of the contract with money attached, so it is worth reading twice.

A 2xx means the node ran the work. The caller is billed for it. A non-zero exit code inside a 2xx is the caller's own program failing, and they pay for that: they occupied the slot.

Anything that is not 2xx means the node did not deliver the run. The gateway treats it as a backend failure: the caller gets a 502, the attempt is recorded as failed so it is visible in their history, and nothing is billed. If the call carried an Idempotency-Key, the key is released so an honest retry can actually re-run.

So a node that cannot take a job (out of capacity, image unavailable, shutting down) should say so with a non-2xx status. Returning 2xx with an invented non-zero exit code would charge someone for a run that never happened.

The gateway does not read the error body of a non-2xx response, and does not forward it. A runner's error text has a habit of echoing the payload back, and caller code does not belong in an error path. Only the status code is used, and the caller sees a generic error class.

Transport window

The gateway allows timeout_secs + 15 seconds on the HTTP call before it gives up. The extra window is deliberate slack: a run that genuinely hits its timeout should be reported by the node as "timed_out": true, not masked as a transport failure at our end. A node that needs longer than that to answer is recorded as a backend failure: unbilled, but also useless to the caller.

Persistent sessions

A /run is one container, start to finish. A session is a long-lived container with a writable /workspace that survives between commands — the shape a coding agent needs, and the reason sandbox sessions exist. A node that implements only /run still works; the gateway simply cannot schedule sessions onto it.

POSTPath/sessionsBody → response{image, vcpu, mem_mb, network, egress_allow, idle_timeout_secs, max_lifetime_secs, env, snapshot_id}{session_id, status, image, …}
GETPath/sessionsBody → response{sessions: [...]}
GETPath/sessions/:idBody → response→ state including alive_secs, idle_secs, exec_count
DELETEPath/sessions/:idBody → response{stopped: true}
POSTPath/sessions/:id/execBody → response{argv, stdin, timeout_secs, env} → the same shape /run answers with
POSTPath/sessions/:id/exec-streamBody → responsesame body, NDJSON, exactly like /run-stream
PUTPath/sessions/:id/files/<path>Body → responseraw bytes (≤ 8 MiB) → {path, bytes}
GETPath/sessions/:id/files/<path>Body → response→ raw bytes (≤ 8 MiB)
GETPath/sessions/:id/files?path=Body → response{path, entries: [{name, type, size}]}
POSTPath/sessions/:id/snapshotBody → response{id, image, bytes, created_at, expires_at}
GET / DELETEPath/snapshots/:idBody → responsemetadata / delete

POST /sessions answers 429 when the node is at its session cap, and the gateway turns that into a 429 for the caller — "retry shortly", not "your request was wrong". A 2xx with no session_id is treated as a failed start, because a session the gateway cannot address is one it would charge for and never be able to reach.

An exec's deadline has to be enforced inside the container. Killing the docker exec client does not kill what it started, so a node that only kills its own process leaves the workload running and the caller paying.

Snapshots

POST /sessions/:id/snapshot captures the workspace and returns an id. The archive never leaves the node that took it — the gateway stores the id, the byte count and the expiry, nothing else — so a session started from a snapshot is pinned to that node. A node is expected to expire its own archives on a TTL and refuse (507) once its store is full, rather than growing without bound.

Egress allowlists

egress_allow on a /run job or a session (with network: true) names the hosts that workload may reach: exact, or a *.suffix wildcard which also matches the suffix itself. The reference runner implements it by putting the container on an internal network with no default route and no working DNS, alongside a forward proxy that is its only way out, with HTTP_PROXY / HTTPS_PROXY injected.

Be precise about what that buys. It is a hostname allowlist, not traffic inspection: TLS is not intercepted, so the node learns which hosts were asked for and nothing inside the tunnel. Secrets stay in env. A node that cannot enforce the allowlist should refuse the job rather than run it unrestricted — silently ignoring egress_allow would turn a stated boundary into a fiction.

Inference nodes (preview)

Everything above describes a sandbox node — a machine that runs a job and returns its output. A node can also serve tokens, and the contract for that is deliberately not ours: an inference node is any endpoint speaking the OpenAI wire format at POST {base}/v1/chat/completions, which is what a stock vLLM or TGI server already is. There is nothing to implement.

Honest status: no house GPU node is enrolled today. The registry, the scheduler, the metering and the ceilings below are built and tested; the hardware is not bought. Every inference request is served by the configured upstream providers, exactly as before, and GET /v1/nodes will tell you so.

A node declares what it serves at enrolment (and may refresh it on any heartbeat):

{
  "name": "gpu-fra-1",
  "endpoint_url": "https://gpu-fra-1.example.com",
  "runner_key": "<the node's own inbound bearer>",
  "kinds": "inference",
  "models": "saxeo-llama-3.3-70b,saxeo-qwen3-coder",
  "model_map": { "saxeo-llama-3.3-70b": "meta-llama/Llama-3.3-70B-Instruct" },
  "max_concurrency": 4,
  "speed_factor": 1.0,
  "tokens_per_sec_bench": 120
}

Scheduling is the same as for sandboxes: the gateway claims a concurrency slot, dials the node, and releases the lease at the terminal outcome. An online node that declares the model is tried before the configured upstream pool; if it is dead, slow to the point of timing out, or answers an error, the request falls through to that pool. A node failure costs margin, never availability. In this first version only buffered completions are node-served — streaming takes the upstream pool, for the reason in the next paragraph.

Your usage report is bounded, not trusted

The usage block a node returns decides what the buyer is charged. An unbounded self-report is an invoice the seller writes, so the gateway bounds it by something that cannot be argued with: the bytes that actually crossed the wire. Prompt tokens are capped at ceil(request_bytes / 2) + 16 and completion tokens at response_content_bytes + 16 — generous by roughly 2× against real tokenizers, and deliberately tokenizer-free so no one has to agree on a tokenizer. A node can always cap out; it can never bill past what it transferred. Under-reporting is left alone.

Clamping is counted per node and published on GET /v1/nodes as clamp_count, alongside the node's declared models, speed_factor and this gateway's own observed reliability / latency_ms. A rising count is evidence, not an accusation — a mis-set chat template trips it too — which is exactly why it is a number anyone can read rather than an automatic penalty. This is also why streaming is not node-served yet: a streamed usage block arrives after the bytes are already gone, so it cannot be bounded before it is billed.

The receipt names the machine. A fleet-served request reports its node as node:<id> in the receipt and in your usage ledger, so you can tell from the receipt alone — without asking us — that a fleet machine served you.

What a node is holding

Anything implementing this contract holds stdin and env in plaintext for the duration of the run, and produces stdout and stderr. On the gateway side those four values are request-scoped: they cross in-frame, go back to the caller, and are never written to a database row or a log line; the only content-derived value that persists is a hash prefix. See the privacy contract.

The gateway can enforce that for itself. It cannot enforce it inside someone else's machine, which is the honest reason enrolment is not simply an open endpoint.

What this contract is not

Still deliberately absent:

Enrolment, heartbeats, and per-job scheduling are built and in production. Execution across machines Saxeo does not own remains a direction, not something this document describes as built.

Stability

What is written above is what the code does today, and publishing it early was the whole point: the shape settled before anything connected to it, and the fleet now runs against it in production. Fields may be added over time, so a node should ignore request fields it does not recognise, exactly as the gateway ignores response fields it does not read. When the contract changes, the change lands on this page.