Portal
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 Sable-operated house node. Zero third-party machines serve Sable 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
}
FieldTypeMeaning
imagestringContainer image to run the work in. Resolved by the gateway from the caller's language, or taken verbatim if the caller named an image.
argvarray of stringsThe interpreter invocation, executed inside the image, e.g. ["python3", "-"], ["node", "-"], ["bash"]. This is not the caller's code.
stdinstringThe submitted code, to be written to the process's standard input. Never empty: the gateway rejects an empty payload before it dispatches.
timeout_secsintegerWall-clock budget for the run. Already clamped to the deployment maximum, so it needs no second-guessing.
vcpuintegervCPUs the run gets. Already clamped.
mem_mbintegerMemory in megabytes. Already clamped.
networkbooleanfalse means no egress: the process must not be able to reach the network. false unless the caller explicitly asked for network.
envobject of string to string, or nullEnvironment 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
}
FieldTypeMeaning
stdoutstringStandard output of the process. Missing or non-string is read as "".
stderrstringStandard error of the process. Missing or non-string is read as "".
exit_codeinteger or nullProcess exit status.
timed_outbooleantrue 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_outexit_codeRecorded aserror_class
trueanythingtimeouttimeout
falseexactly 0succeedednone
falseanything else, or absentfailednonzero_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.

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