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.
Files
| Method | Path | What it does |
|---|---|---|
| PUT | /v1/sandboxes/sessions/{id}/files/{path} | Write one file (raw body, ≤ 8 MiB). |
| GET | /v1/sandboxes/sessions/{id}/files/{path} | Read one file back, as raw bytes. |
| GET | /v1/sandboxes/sessions/{id}/files?path= | List 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 | /v1/sandboxes/sessions | Open a workspace. Accepts Idempotency-Key. |
| GET | /v1/sandboxes/sessions | Your sessions, newest first (metadata only). |
| GET | /v1/sandboxes/sessions/{id} | One session: status, alive seconds, exec count, cost. |
| POST | /v1/sandboxes/sessions/{id}/exec | Run a command. stream:true for SSE. |
| PUT | /v1/sandboxes/sessions/{id}/files/{path} | Upload one file (≤ 8 MiB). |
| GET | /v1/sandboxes/sessions/{id}/files/{path} | Download one file (≤ 8 MiB). |
| GET | /v1/sandboxes/sessions/{id}/files | List a directory (?path=). |
| POST | /v1/sandboxes/sessions/{id}/snapshot | Snapshot the workspace. |
| DELETE | /v1/sandboxes/sessions/{id} | Stop it. |
| GET | /v1/sandboxes/snapshots | Your snapshots. |
| GET | /v1/sandboxes/snapshots/{id} | One snapshot's metadata. |
| DELETE | /v1/sandboxes/snapshots/{id} | Delete 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.