sandbin

API

One HTTP endpoint to submit a run, one WebSocket to watch it happen. npm start serves both on PORT (default 8080).

Submit a run

POST /runs queues a job and returns immediately — it never blocks waiting for the run to finish:

curl -s -X POST localhost:8080/runs \
  -H 'content-type: application/json' \
  -d '{"language":"python","code":"print(1+1)"}'

# -> {"accepted":true,"runId":"...","position":0}
FieldTypeNotes
languagestringpython, bash, node, c, or go if the host running sandbin has a Go toolchain
codestringsource to run
stdinstringoptional, written before the guest starts reading
limitsobjectoptional overrides — memoryBytes, cpuPercent, wallClockMs, pids, and others

A rejection has the same response shape with accepted: false and no runId: HTTP 429 for queue_full, key_limit or rate_limited, 400 for a malformed request (unknown language, missing code). The submitter's key for per-key concurrency limiting is the X-Sandbin-Key header if present, otherwise their IP.

Watch it stream

GET /runs/:runId/stream, upgraded to a WebSocket, delivers the run's story as one JSON message per frame:

{ "type": "queued", "position": 2 }
{ "type": "started" }
{ "type": "stats", "t": 150, "memBytes": 20971520, "cpuMs": 8 }
{ "type": "chunk", "stream": "stdout", "text": "2\n" }
{ "type": "finished", "result": { "verdict": "ok", "stdout": "2\n", ... } }
TypeWhen
queuedconnected before the run left the queue — carries its position
startedthe sandbox actually spawned; any buffered stdin can now be sent
statsa live cgroup sample — memory.current and cumulative CPU time, polled every 50 ms while the guest runs
chunka piece of stdout or stderr, as it's produced — not buffered until exit
finishedterminal message: full result object, socket closes after
errore.g. connecting to an unknown runId

stats carries t (milliseconds since the guest started), memBytes and cpuMs — enough to draw a live resource graph while a longer-running submission is still executing. Short runs may finish before a single sample is taken; that's expected, not a bug.

Connecting mid-run replays every chunk seen so far before continuing live. Connecting after the run has already finished — routine, given ~20 ms cold starts racing a WebSocket handshake — replays just the final finished message and closes. Either way the client sees the same story; it's the buffering that changes, never the guarantee.

Interactive stdin

Sending { "type": "stdin", "text": "..." } over the socket writes to the guest's stdin while it's still running — this is what makes a real, blocking input() call work, not just a fixed string supplied up front. { "type": "stdin_close" } sends EOF.

Verdicts

VerdictMeaning
okexited zero
errorexited nonzero, or crashed
timeouthit wallClockMs
memory_limitcgroup OOM-killed it
output_limitstdout+stderr exceeded the byte cap
chunk_limithit the chunk-count cap while still under the byte cap — a program flushing very frequently in tiny writes, not one producing too much output
killedthe supervisor's own hard backstop fired
compile_errorc only — compilation itself failed; stderr is the compiler's diagnostic
setup_failedcgroup assignment failed — reported, never silently ignored
spawn_failedthe guest process itself never launched (fd/process exhaustion on the host) — stderr carries the underlying error

Every result also carries cpuMs, peakBytes, oomKills and pidsMaxHits, read straight from the cgroup.

Backpressure

Submissions queue behind a bounded worker pool (maxConcurrency) with a capped backlog (maxQueueLength) and a per-key concurrency limit (maxPerKey). Once either cap is hit, POST /runs rejects immediately rather than accepting unbounded work or shrinking every run's resource limits to compensate.

API keys and rate limits

Anonymous callers, keyed by IP, get 20 runs/hour. POST /keys issues a free API key with a higher 200 runs/hour quota, no signup:

curl -s -X POST localhost:8080/keys
# -> {"key":"sb_...","requestsPerHour":200}

Send it back as X-Sandbin-Key on POST /runs to run under that quota instead of the anonymous one. GET /keys/:key reports the current window without consuming it:

curl -s localhost:8080/keys/sb_...
# -> {"key":"sb_...","requestsPerHour":200,"used":3,"remaining":197,"resetAt":...}

The quota is a fixed one-hour window, tracked in memory and separate from the X-Sandbin-Key header's other job of partitioning maxPerKey concurrency: an arbitrary caller-chosen string still works for concurrency partitioning at the anonymous rate-limit tier, and only resolves to the higher tier when it matches a key actually issued by POST /keys. Key issuance itself is rate-limited by IP (5/hour) so it can't be used to mint unlimited fresh quotas.

Permalinks

Every finished run is saved to disk and gets a shareable link: GET /r/:runId serves an HTML page that replays the code, the streamed output, and the resource graph exactly as they happened — timed from the real chunk/stats timestamps, capped at 800 ms per step so a run that hit a long timeout doesn't force a visitor to sit through the dead air. GET /r/:runId/data is the JSON it's built from.

Links expire after 30 days, checked lazily on read and swept hourly — there's no database, just one JSON file per run under data/runs/. The id is the same runId returned by POST /runs, an unguessable UUID: unlisted, not secret. Sharing a link shares exactly what was submitted and what it produced, nothing more.

Metrics

GET /metrics is a live dashboard of the server process itself; GET /metrics/data is the JSON it renders from — submitted/accepted/rejected counts, finished runs by verdict and by language, average duration/CPU/peak memory, live queue depth, and API keys issued. Both reset on restart (in-memory counters, no database) and are open by default. Set both SANDBIN_METRICS_USER and SANDBIN_METRICS_PASS to require HTTP Basic Auth instead — a 401 with WWW-Authenticate: Basic otherwise, which a browser's own native prompt handles without any frontend changes.