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}
| Field | Type | Notes |
|---|---|---|
language | string | python, bash, node, c, or go if the host running sandbin has a Go toolchain |
code | string | source to run |
stdin | string | optional, written before the guest starts reading |
limits | object | optional 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", ... } }
| Type | When |
|---|---|
queued | connected before the run left the queue — carries its position |
started | the sandbox actually spawned; any buffered stdin can now be sent |
stats | a live cgroup sample — memory.current and cumulative CPU time, polled every 50 ms while the guest runs |
chunk | a piece of stdout or stderr, as it's produced — not buffered until exit |
finished | terminal message: full result object, socket closes after |
error | e.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
| Verdict | Meaning |
|---|---|
ok | exited zero |
error | exited nonzero, or crashed |
timeout | hit wallClockMs |
memory_limit | cgroup OOM-killed it |
output_limit | stdout+stderr exceeded the byte cap |
chunk_limit | hit the chunk-count cap while still under the byte cap — a program flushing very frequently in tiny writes, not one producing too much output |
killed | the supervisor's own hard backstop fired |
compile_error | c only — compilation itself failed; stderr is the compiler's diagnostic |
setup_failed | cgroup assignment failed — reported, never silently ignored |
spawn_failed | the 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.