Sandbox sessions
POST /v1/sandboxes runs code in a fresh container and throws it away. That is
the right shape for a one-shot job and the wrong shape for the loop a coding
agent actually runs: write a file, run the tests, read the failure, edit, run
again. Doing that against one-shot runs means re-uploading the whole working
state on every call, and paying to rebuild it each time.
A session is a long-lived container with a writable /workspace that
survives between commands. Same API key, same balance, same spend caps, same
signed receipt per command — the only thing that changed is that the filesystem
was already there.
Open a workspace
curl https://api.buildsable.com/v1/sandboxes/sessions \
-H "authorization: Bearer $SABLE_API_KEY" \
-H 'content-type: application/json' \
-H 'idempotency-key: agent-run-42' \
-d '{
"language": "python",
"vcpu": 1,
"mem_mb": 1024,
"idle_timeout_secs": 300
}'Pass an Idempotency-Key header on the create. A retry under the same key
returns the session you already have, rather than opening a second container
you would keep paying for and never know about.
Running commands
POST /v1/sandboxes/sessions/{id}/exec takes either code (with an optional
language) or an explicit argv such as ["pytest", "-q"], which runs with
/workspace as the working directory. It returns the same fields a one-shot run
returns, plus a signed receipt whose payload carries session_id.
Add "stream": true to get stdout and stderr over SSE as they are produced —
identical framing to streaming sandbox runs, with a terminal
sable.result event carrying the full response. For a command that takes
minutes, the stream is also the liveness signal.
A session is not a way around a key's guardrails. If the
key's policy names a rule set, its blocklist is run on every exec exactly
as on a one-shot run: over the code, or over the argv command line together
with stdin. The other detectors do not run on code, env is never read, and
nothing is rewritten. Output and the workspace are not screened, and neither
are files written through the endpoints below. With enforcement on, a blocked
exec is refused with 422 before anything is reserved or executed (as a
sable.error event when streaming); see
Sandbox compute → Guardrails.
Files
| Method | Path | What it does |
|---|---|---|
| PUT | Path/v1/sandboxes/sessions/{id}/files/{path} | What it doesWrite one file (raw body, ≤ 8 MiB). |
| GET | Path/v1/sandboxes/sessions/{id}/files/{path} | What it doesRead one file back, as raw bytes. |
| GET | Path/v1/sandboxes/sessions/{id}/files?path= | What it doesList a directory. |
Paths are workspace-relative. A leading / or any .. segment is rejected at
both ends, by the gateway and again by the node.
These are relays, not storage. The bytes cross the gateway in-frame and are written nowhere: there is no file column, and there is no way to ask Sable for a file you did not keep. The workspace lives on the node for as long as the session does, and goes with it.
Snapshots
POST /v1/sandboxes/sessions/{id}/snapshot captures the workspace and returns
an id you can hand to a later create as snapshot_id — a prepared
environment (dependencies installed, repository cloned) reused instead of
rebuilt.
The archive stays on the node that took it. Sable records the id, the byte count and an expiry; it never holds the contents. Two consequences worth knowing: a session started from a snapshot is pinned to that node, and snapshots expire — treat one as a warm cache, not as durable storage.
GET /v1/sandboxes/snapshots lists yours, DELETE /v1/sandboxes/snapshots/{id}
removes one.
Network egress
Egress is off by default, exactly as for a one-shot run. network: true opens
it; egress_allow narrows it to named hosts:
{
"language": "python",
"network": true,
"egress_allow": ["pypi.org", "*.pythonhosted.org"]
}
An entry is either an exact hostname or a *.suffix wildcard, which also
matches the suffix itself. With a non-empty allowlist the node places the
workspace on an internal network with no default route and no DNS of its
own, alongside a forward proxy that is its only way out, and injects
HTTP_PROXY / HTTPS_PROXY so ordinary clients use it. A request to anything
not on the list is refused at the proxy, and so is any host that resolves to a
private or loopback address.
Be precise about what this is and is not. It is a hostname allowlist, not
traffic inspection: TLS is not intercepted. Sable learns which hosts a
workspace was permitted to reach and nothing about what travelled inside those
connections. Values you put in env stay between you and your own code. If you
need a stricter boundary than "these hostnames", this is not it.
What it costs
Two meters, because there are two different things being consumed.
- Alive time. A warm container holds memory and a concurrency slot on a node
whether or not anything is running in it, so it is metered per second at a
fraction of the active rate (25% by default, operator-configurable), scaled by
the vCPU and memory the workspace reserves. It appears in your usage as kind
sandbox_session, unitseconds. - Each command. Metered at the full active rate for the wall-clock seconds
it runs, as kind
sandbox— the same price as a one-shot run of the same shape.
So an idle workspace is cheap but never free, and that is deliberate: capacity you are holding is capacity nobody else can be sold. A session reserves its next window of alive time as a credit hold and rolls it forward; if your balance can no longer cover the window, the session is stopped rather than allowed to accrue a bill you cannot pay.
When a session ends
A workspace stops when you DELETE it, when it has been idle past its
idle_timeout_secs (default 300 s), when it reaches max_lifetime_secs
(4 hours by default), when its node becomes unreachable, or when credit runs
out. A sandbox_session_stopped webhook fires with the
reason.
A stopped session is gone, not paused: the container and everything in it no
longer exist, and a command against it answers 409 rather than quietly
starting a fresh one. Snapshot first if you want the state back. Stopping is
idempotent — a second DELETE bills nothing further.
Endpoints
| Method | Path | Notes |
|---|---|---|
| POST | Path/v1/sandboxes/sessions | NotesOpen a workspace. Accepts Idempotency-Key. |
| GET | Path/v1/sandboxes/sessions | NotesYour sessions, newest first (metadata only). |
| GET | Path/v1/sandboxes/sessions/{id} | NotesOne session: status, alive seconds, exec count, cost. |
| POST | Path/v1/sandboxes/sessions/{id}/exec | NotesRun a command. stream:true for SSE. |
| PUT | Path/v1/sandboxes/sessions/{id}/files/{path} | NotesUpload one file (≤ 8 MiB). |
| GET | Path/v1/sandboxes/sessions/{id}/files/{path} | NotesDownload one file (≤ 8 MiB). |
| GET | Path/v1/sandboxes/sessions/{id}/files | NotesList a directory (?path=). |
| POST | Path/v1/sandboxes/sessions/{id}/snapshot | NotesSnapshot the workspace. |
| DELETE | Path/v1/sandboxes/sessions/{id} | NotesStop it. |
| GET | Path/v1/sandboxes/snapshots | NotesYour snapshots. |
| GET | Path/v1/sandboxes/snapshots/{id} | NotesOne snapshot's metadata. |
| DELETE | Path/v1/sandboxes/snapshots/{id} | NotesDelete a snapshot. |
Sessions need the http-runner backend — the workspace, its files, its
snapshots and its egress proxy all live on the node. A deployment running the
local Docker backend answers 501 and points you at POST /v1/sandboxes.
Privacy
Unchanged from the privacy contract. The code a command submits, its environment, its output and every file in the workspace cross the gateway in-frame and are never written to a database row or a log line. The session table holds ids, sizes, timings, counts and money; there is no column that could hold your work. Snapshots are the one thing kept on disk — on the node, by your explicit request, under a TTL — and Sable stores an id and a byte count, never the contents.