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:
- Configured: the deployment points at it directly
(
SAXEO_SANDBOX_BASE/_KEY), the original single-backend mode, kept as the fallback. - Enrolled: the node is registered in the fleet registry with an
endpoint, hardware descriptor, concurrency limit, and egress policy, and
holds a node credential (
nk-sable_…), revealed exactly once by the session-authedPOST /v1/nodes/enroll. It then POSTs/v1/nodes/heartbeat(bearer: its node key) every 60 seconds; a node that misses ~2 beats goes stale and leaves the rotation. The gateway claims a concurrency slot per job in a database transaction before dialing, so a node is never sent more parallel work than it declared. Today every enrolled node is a Saxeo-operated house node; enrollment is not open to third parties (see /operators).
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
}
| Field | Type | Meaning |
|---|---|---|
image | Typestring | MeaningContainer image to run the work in. Resolved by the gateway from the caller's language, or taken verbatim if the caller named an image. |
argv | Typearray of strings | MeaningThe interpreter invocation, executed inside the image, e.g. ["python3", "-"], ["node", "-"], ["bash"]. This is not the caller's code. |
stdin | Typestring | MeaningThe submitted code, to be written to the process's standard input. Never empty: the gateway rejects an empty payload before it dispatches. |
timeout_secs | Typeinteger | MeaningWall-clock budget for the run. Already clamped to the deployment maximum, so it needs no second-guessing. |
vcpu | Typeinteger | MeaningvCPUs the run gets. Already clamped. |
mem_mb | Typeinteger | MeaningMemory in megabytes. Already clamped. |
network | Typeboolean | Meaningfalse means no egress: the process must not be able to reach the network. false unless the caller explicitly asked for network. |
env | Typeobject of string to string, or null | MeaningEnvironment variables for the process. null when the caller sent none. |
Three things worth being blunt about:
argvandstdinare separate for a reason. The code arrives on stdin, not as an argument and not baked into a command line, so there is nothing to quote and nothing to escape.envis passed through as the caller wrote it. The gateway does not rewrite or filter names and values on this path. A node treats both as untrusted input.- There is no image allowlist on the gateway side beyond the per-key resource allowlist described in API key controls. Which images a node is willing to pull and run is the node's own decision.
Response
A 2xx with this body:
{
"stdout": "45\n",
"stderr": "",
"exit_code": 0,
"timed_out": false
}
| Field | Type | Meaning |
|---|---|---|
stdout | Typestring | MeaningStandard output of the process. Missing or non-string is read as "". |
stderr | Typestring | MeaningStandard error of the process. Missing or non-string is read as "". |
exit_code | Typeinteger or null | MeaningProcess exit status. |
timed_out | Typeboolean | Meaningtrue 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:
timed_out | exit_code | Recorded as | error_class |
|---|---|---|---|
true | exit_codeanything | Recorded astimeout | error_classtimeout |
false | exit_codeexactly 0 | Recorded assucceeded | error_classnone |
false | exit_codeanything else, or absent | Recorded asfailed | error_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.
| Method | Path | Body → response |
|---|---|---|
| POST | Path/sessions | Body → response{image, vcpu, mem_mb, network, egress_allow, idle_timeout_secs, max_lifetime_secs, env, snapshot_id} → {session_id, status, image, …} |
| GET | Path/sessions | Body → response→ {sessions: [...]} |
| GET | Path/sessions/:id | Body → response→ state including alive_secs, idle_secs, exec_count |
| DELETE | Path/sessions/:id | Body → response→ {stopped: true} |
| POST | Path/sessions/:id/exec | Body → response{argv, stdin, timeout_secs, env} → the same shape /run answers with |
| POST | Path/sessions/:id/exec-stream | Body → responsesame body, NDJSON, exactly like /run-stream |
| PUT | Path/sessions/:id/files/<path> | Body → responseraw bytes (≤ 8 MiB) → {path, bytes} |
| GET | Path/sessions/:id/files/<path> | Body → response→ raw bytes (≤ 8 MiB) |
| GET | Path/sessions/:id/files?path= | Body → response→ {path, entries: [{name, type, size}]} |
| POST | Path/sessions/:id/snapshot | Body → response→ {id, image, bytes, created_at, expires_at} |
| GET / DELETE | Path/snapshots/:id | Body → 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
}
kindsis a CSV — a machine may besandbox,inference, or both.modelsnames Saxeo ids. Every id is validated at enrolment, and two classes are refused outright: a model pinned to a named provider (a privacy route, where which machine answers is the product) and a confidential model (attestation is a claim about specific measured hardware, and a fleet node has none). A node cannot even declare one.model_mapis optional, and only needed when the node's own server answers to a different id than the catalog's upstream id.speed_factornormalizes throughput against the reference so that, when earnings exist, a slow node cannot be paid more for the same work.
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:
- No earnings, settlement, or payout mechanics.
- No attestation or hardware-proof exchange.
- No third-party enrolment. The registry, heartbeat, and scheduler are live, but every enrolled node is operated by Saxeo.
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.