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:
- Configured: the deployment points at it directly
(
SABLE_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 Sable-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 | string | Container 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 | array of strings | The interpreter invocation, executed inside the image, e.g. ["python3", "-"], ["node", "-"], ["bash"]. This is not the caller's code. |
stdin | string | The submitted code, to be written to the process's standard input. Never empty: the gateway rejects an empty payload before it dispatches. |
timeout_secs | integer | Wall-clock budget for the run. Already clamped to the deployment maximum, so it needs no second-guessing. |
vcpu | integer | vCPUs the run gets. Already clamped. |
mem_mb | integer | Memory in megabytes. Already clamped. |
network | boolean | false means no egress: the process must not be able to reach the network. false unless the caller explicitly asked for network. |
env | object of string to string, or null | Environment 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 | string | Standard output of the process. Missing or non-string is read as "". |
stderr | string | Standard error of the process. Missing or non-string is read as "". |
exit_code | integer or null | Process exit status. |
timed_out | boolean | true 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 | anything | timeout | timeout |
false | exactly 0 | succeeded | none |
false | anything else, or absent | failed | nonzero_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:
- 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 Sable.
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.