REST API
Every HTTP endpoint, request/response shape, and status code.
The API gateway exposes a small, internal HTTP surface. All /v1/* endpoints
require an Authorization: Bearer <EXECUTOR_API_TOKEN> header; a missing or invalid
token returns 401 {"error":"unauthorized"} (compared in constant time). GET /health
is the only unauthenticated route.
Base URL in local dev: http://localhost:8080.
GET /health
Unauthenticated readiness probe.
GET /health → 200 {"status":"ok"}POST /v1/execute
Submit code for execution. Returns immediately, before any process starts, with a job ID and the soketi channel to subscribe to.
Request body
{
"language": "python",
"version": "3.12",
"files": [
{ "name": "main.py", "content": "name = input('name? ')\nprint(f'hello {name}')\n" }
],
"limits": {
"wallTimeMs": 30000, "idleMs": 10000, "cpuMs": 15000,
"memoryMb": 128, "pids": 64, "outputKb": 512,
"maxArtifacts": 20, "maxArtifactBytes": 4194304
},
"collectOutput": false
}| Field | Required | Notes |
|---|---|---|
language | yes | Name or alias ("python", "py", "rust", "rs", "sqlite", "sql"). |
version | no | Omit to use the only/most-recent match. |
files | yes | Array of FileInput; at least one. File names 1–255 chars. See file inputs. |
limits | no | Per-request overrides; absent fields use manifest defaults. |
collectOutput | no | true to persist a pullable RunResult. Defaults to false. |
File inputs
Each entry in files is a FileInput. Every file is materialized under the sandbox
workspace (/workspace) before the entrypoint runs.
| Field | Required | Notes |
|---|---|---|
name | yes | Relative path under /workspace. May contain / for subdirectories (e.g. data/input.csv). Absolute paths (/etc/passwd) and .. traversal are rejected with 400. |
content | conditional | Inline file body. UTF-8 text by default; base64-encoded bytes when encoding:"base64". Mutually exclusive with ref. |
encoding | no | "utf8" (default, back-compatible) or "base64" for arbitrary bytes (xlsx, images, zip, …). Ignored when ref is set. |
ref | conditional | "sha256:<64-hex>" — a pre-uploaded content-addressed blob. Mutually exclusive with content. |
A FileInput carries exactly one of content or ref. The content/ref XOR is
enforced in both the API and the worker; the worker re-sanitizes every path regardless
of the API (host-escape-only threat model). See Multi-file input
and Large & shared files.
Responses
| Code | Body | When |
|---|---|---|
202 | {"jobId","channel":"private-run-<jobId>","status":"queued"} | Accepted; job enqueued. |
400 | {"error","details":[...]} | Invalid body, unknown language/version, invalid base64 content, absolute or ..-traversing name, or a file with both / neither content and ref / a malformed ref. |
413 | {"error":...} | Total decoded size of all files exceeds MAX_FILES_BYTES (default 8 MiB). For base64 files the decoded byte length counts, not the larger base64 string. |
429 | {"error":"Executor at capacity...","retryAfterMs":1000} | LLEN(jobs:queue) >= MAX_QUEUE_DEPTH. Checked after manifest resolution, so invalid bodies get 400, not 429. |
POST /v1/jobs/:id/start
The start-handshake signal. Call it after your client has subscribed to the job's soketi channel. See Request Lifecycle.
POST /v1/jobs/:id/start → 202 {"ok":true}Persists a durable start flag (start:<jobId>, with a TTL) and publishes
{type:"start"} on ctrl:<jobId>. The durable flag is the source of truth: a job
still waiting in the queue when /start is called has no worker subscribed to
ctrl:<jobId> yet, so the publish alone would be lost — the worker reads the flag
when it claims the job. This makes the handshake safe under queue backpressure (the
common case when autoscaling lags a burst).
POST /v1/jobs/:id/stdin
Write a chunk to the running process's stdin.
Request body
{ "chunk": "World\n" }Responses
| Code | Body | When |
|---|---|---|
200 | {"ok":true} | Chunk published to stdin:<jobId>. |
400 | {"error","details":[...]} | Invalid body (missing chunk). |
429 | {"error":"...","retryAfterMs":1000} or {"error":"...","capBytes":65536} | Frame-rate cap (30 frames/s) or pending-byte cap (64 KiB) exceeded. |
POST /v1/jobs/:id/stdin/close
Signal EOF to stdin (like Ctrl-D). Publishes {type:"stdin_close"} on ctrl:<jobId>.
POST /v1/jobs/:id/stdin/close → 200 {"ok":true}POST /v1/jobs/:id/kill
SIGKILL the sandbox. Publishes {type:"kill"} on ctrl:<jobId>.
POST /v1/jobs/:id/kill → 200 {"ok":true}GET /v1/jobs/:id
Poll job status.
Responses
| Code | Body | When |
|---|---|---|
200 | JobStatus | Job found. |
404 | {"error":"Job not found: <id>"} | Unknown job id. |
JobStatus
{
"jobId": "<uuid>",
"channel": "private-run-<uuid>",
"language": "python",
"version": "3.12",
"state": "running",
"updatedAtMs": 1700000000000
}state ∈ queued · starting · running · done · killed · error.
GET /v1/jobs/:id/status
Alias of GET /v1/jobs/:id — returns the same JobStatus. Provided as an explicit
sub-path (mirroring /output) for late-join reconciliation: a client that
subscribes to the soketi channel after the job already moved past queued missed
those stage events, so it pulls the authoritative state here on subscribe instead of
waiting for the next event. The React SDK's
onResolveStatus callback wires this for you.
Responses
| Code | Body | When |
|---|---|---|
200 | JobStatus | Job found. |
404 | {"error":"Job not found: <id>"} | Unknown job id. |
GET /v1/jobs/:id/output
Pull the persisted RunResult for a job that ran with collectOutput: true.
Responses
| Code | Body | When |
|---|---|---|
200 | RunResult | Output present. |
404 | {"error":...} | Unknown id, output not collected, or past TTL. |
RunResult
{
"exitCode": 0,
"signal": null,
"timedOut": false,
"idleTimedOut": false,
"truncated": false,
"durationMs": 312,
"stdout": "hello World\n",
"stderr": "",
"artifacts": [
{ "name": "plot.png", "mimeType": "image/png", "bytes": 20481, "url": "https://.../plot.png?X-Amz-..." }
],
"artifactsTruncated": false
}For compiled languages, an optional compile field carries the build stage,
kept distinct from the run stdout/stderr. It's absent for interpreted languages.
A non-zero compile.exitCode means compilation failed and the run stage did not
execute.
{
"compile": {
"exitCode": 0,
"signal": null,
"stdout": "",
"stderr": "",
"output": "",
"durationMs": 540
}
}compile.output is the interleaved stdout+stderr of the compile command in emission
order (mirrors Piston's compile.output).
See Artifacts & Pullable Output.
GET /v1/languages
List the languages discovered from languages/*/manifest.json. No language
identifiers are hardcoded.
[
{ "language": "python", "version": "3.12", "aliases": ["py","py3","python3"], "interactive": true },
{ "language": "rust", "version": "1.83", "aliases": ["rs"], "interactive": true },
{ "language": "r", "version": "4.4", "aliases": ["R"], "interactive": false },
{ "language": "sqlite", "version": "3", "aliases": ["sql"], "interactive": true }
]Blobs
Content-addressed storage for large or reused input files. A file is identified
by the sha256 of its bytes, uploaded once to the configured object store, and then
referenced from any job by ref: "sha256:<hex>" instead of being inlined. This sidesteps
the MAX_FILES_BYTES cap and re-uploading the same bytes on every run. See the
Blobs guide for the full flow, dedup, and TTL/GC behaviour.
The handshake is check → presigned PUT → finalize → execute(ref). The API only
presigns upload URLs (pure local crypto — no S3 call, no byte proxying) and records
liveness in Redis; the bytes go client → store directly. The worker is the only party
that reads blob bytes, and verifies sha256 == ref authoritatively on pull.
Both blob routes require the object store to be configured server-side (the same
S3 endpoint/bucket/creds as artifacts). If it is unconfigured, /v1/blobs/* returns
501 and you should keep using inline files. See
Configuration.
POST /v1/blobs/check
Ask which of a set of blobs the store already has. Missing blobs come back with a presigned PUT URL; present blobs have their idle TTL refreshed.
Request body
{ "hashes": ["sha256:9f86d0…<64 hex>", "sha256:2c26b4…<64 hex>"] }Responses
| Code | Body | When |
|---|---|---|
200 | {"missing":[{"hash","uploadUrl"}],"present":["sha256:…"]} | Existence checked. uploadUrl is a presigned PUT (default 15-min window). |
400 | {"error","details":[...]} | Malformed hash (not sha256:<64-hex>). |
501 | {"error":...} | Blob store unconfigured. |
After receiving missing, PUT the raw bytes of each missing blob to its uploadUrl
(client → store direct).
POST /v1/blobs/finalize
Record liveness for blobs whose bytes you just uploaded. The API sets blob:meta:<hash>
(idle TTL) and adds membership in blobs:index. It does not read the bytes —
integrity is verified by the worker on pull.
Request body
{ "hashes": ["sha256:9f86d0…<64 hex>"] }Responses
| Code | Body | When |
|---|---|---|
200 | {"finalized":["sha256:…"]} | Liveness recorded/refreshed. |
400 | {"error","details":[...]} | Malformed hash. |
501 | {"error":...} | Blob store unconfigured. |
Then submit the job with POST /v1/execute using files: [{ name, ref: "sha256:<hex>" }].
The client.blobs.upload(buffer) SDK method wraps this entire handshake (and skips the
PUT when the blob is already present).
POST /v1/channel-auth
Optional. Registered only when ENABLE_CHANNEL_AUTH=true. Intended for local
demos — in production your own backend signs channel auth (see
Channel Auth).
Request body
{ "socket_id": "123.456", "channel_name": "private-run-<jobId>" }Responses
| Code | Body | When |
|---|---|---|
200 | {"auth":"<app_key>:<hmac>"} | Channel authorized. |
400 | {"error":"Missing required fields: socket_id, channel_name"} | Missing fields. |
403 | {"error":"Only private-run-<jobId> channels can be authorized here"} | Non-private-run- channel. |
Typed client
You don't have to call these by hand — the @teovilla/code-runner-sdk-node
client wraps every endpoint with typed methods and typed errors.