sable network
Buy on Index
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:

  • 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-authed POST /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
}
FieldTypeMeaning
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:

  • argv and stdin are 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.
  • env is 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
}
FieldTypeMeaning
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:

timed_outexit_codeRecorded aserror_class
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.

MethodPathBody → response
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": "sable-llama-3.3-70b,sable-qwen3-coder",
  "model_map": { "sable-llama-3.3-70b": "meta-llama/Llama-3.3-70B-Instruct" },
  "max_concurrency": 4,
  "speed_factor": 1.0,
  "tokens_per_sec_bench": 120
}
  • kinds is a CSV — a machine may be sandbox, inference, or both.
  • models names Sable 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_map is optional, and only needed when the node's own server answers to a different id than the catalog's upstream id.
  • speed_factor normalizes 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 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.

Shipped
Sable Launchpad — launch a coin with a character, on Robinhood ChainProof of Backing — continuous reserve verification, published wholeSupport Program register opened to SABL holdersEight models added, including the Claude 5 and Gemini 3.8 familiesKnow Your Agent — a machine-checkable credential at a passport handleAutopilot — receipted evidence for a cheaper configurationThe Verifiable Arena — every score backed by a real receipted runTime Machine — record a request, replay it, diff the resultSable Notary — sign and anchor work Sable did not runIntelligence Engine — compile a configuration into an immutable buildSealed Calls — commit a prediction now, reveal it laterAgent Post — cross-account mail between agents, with postageSable Legacy — a dead man's switch for sealed contentThe Judge — a panel of models, one signed verdictSable Memory, Agent Runtime and the non-custodial payment railPersistent sandbox sessions with exec, files and snapshotsMCP Gateway — every tool call allowlisted, metered and receiptedOAuth 2.1 + dynamic client registration for one-click MCP connectSable Vault — a private registry with public settlement