Running commands
One endpoint runs a shell command inside a sandbox, waits for it to finish, and returns its exit code, stdout and stderr.
The exec endpoint
POST https://sandbox-as-a-service.com/v1/sandboxes/{id}/exec
curl -sS -X POST https://sandbox-as-a-service.com/v1/sandboxes/$SBX/exec \
-H "Authorization: Bearer $AAS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"command": "python3 -c \"import sys; print(sys.version)\"",
"cwd": "/workspace",
"timeout_ms": 30000,
"env": {"LOG_LEVEL": "debug"}
}'import os
import requests
API = "https://sandbox-as-a-service.com/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['AAS_API_KEY']}"}
def run(sandbox_id, command, timeout_ms=60_000, cwd="/workspace", env=None):
r = requests.post(
f"{API}/sandboxes/{sandbox_id}/exec",
headers=HEADERS,
json={"command": command, "timeout_ms": timeout_ms, "cwd": cwd, "env": env or {}},
timeout=timeout_ms / 1000 + 30,
)
r.raise_for_status()
return r.json()
result = run(sandbox_id, "python3 -c 'import sys; print(sys.version)'")
print(result["exit_code"], result["stdout"])const API = "https://sandbox-as-a-service.com/v1";
const headers = {
Authorization: `Bearer ${process.env.AAS_API_KEY}`,
"Content-Type": "application/json",
};
async function run(sandboxId, command, opts = {}) {
const res = await fetch(`${API}/sandboxes/${sandboxId}/exec`, {
method: "POST",
headers,
body: JSON.stringify({
command,
timeout_ms: opts.timeoutMs ?? 60000,
cwd: opts.cwd ?? "/workspace",
env: opts.env ?? {},
}),
});
if (!res.ok) throw new Error(`exec failed: ${res.status} ${await res.text()}`);
return res.json();
}
const result = await run(sandboxId, "node -e 'console.log(process.version)'");
console.log(result.exit_code, result.stdout);
Request fields
| Field | Type | Default | Rules |
|---|---|---|---|
command | string | — | Required, non-empty, at most 100,000 characters. Run through bash, so pipes, &&, redirects and heredocs all work. |
timeout_ms | integer | 60000 | Between 1,000 and 600,000 (10 minutes). |
cwd | string | /workspace | At most 500 characters. Must exist and be readable by the sandbox user, or the command fails with a shell error. |
env | object | {} | String values only. Keys must match [A-Za-z_][A-Za-z0-9_]*. Applied to this command only. |
stream | boolean | false | When true, the response is a Server-Sent Events stream and output arrives as it is produced. See Streaming output. |
Response fields
| Field | Meaning |
|---|---|
id | Execution id (exec_…). Fetch it again later with GET /v1/executions/{id}. |
exit_code | Process exit status. 0 is success; 124 means the command hit timeout_ms and was killed. |
stdout / stderr | Captured output, each capped at 1 MiB. |
truncated | true if either stream hit the cap and output was dropped. |
duration_ms | Wall-clock time the platform spent running the command. |
A command that exits non-zero is still an HTTP 200. The HTTP status describes the API
call; exit_code describes your program. Only conditions outside the command itself —
a missing sandbox, an exhausted balance, a broken connection to the machine — produce a non-2xx status.
Fetching the same execution afterwards with GET /v1/executions/{id} also returns a
status field (completed, timeout, failed or
cancelled) plus started_at and finished_at.
Streaming output
By default exec waits for the command to finish and returns one JSON body with all of its output.
Pass "stream": true and the same call answers with a Server-Sent Events
stream instead: output arrives as the sandbox produces it, so a five-minute build shows its progress
live instead of going quiet until the end.
curl -N -X POST https://sandbox-as-a-service.com/v1/sandboxes/$SBX/exec \
-H "Authorization: Bearer $AAS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"command": "for i in 1 2 3; do echo tick $i; sleep 1; done", "stream": true}'
The response is a Server-Sent Events
stream (Content-Type: text/event-stream) — read it with curl -N, an SSE client,
or the Python SDK (below). Its events, in order:
event: start— once, first, as{"id":"exec_…","sandbox_id":"…"}; the id is the usualexec_…you can fetch later.event: stdout/event: stderr—{"data":"<chunk>"}as output arrives, not at the end. A multi-byte UTF-8 character split across transport chunks is delivered whole.: ping— a comment line at least every 15 seconds, keeping proxies and load balancers from idling the connection out while a command runs quietly.event: exit— exactly one terminal event whose data is the same object the non-streaming call returns (exit_code,status,duration_ms,stdout,stderr,truncatedand so on).event: error— replaces the exit event when the execution itself fails after streaming started; its data is the standard error object. Validation, authentication, an unknown or not-running sandbox and an exhausted balance are still returned as ordinary JSON errors with their usual status codes, before any event stream is opened.
With the Python SDK there is nothing to parse: pass callbacks and the stream is consumed for you — they fire as chunks arrive, and the return value is the same object a blocking call returns.
import sys
result = sandbox.exec(
"for i in 1 2 3; do echo tick $i; sleep 1; done",
on_stdout=lambda chunk: print(chunk, end=""),
on_stderr=lambda chunk: print(chunk, end="", file=sys.stderr),
)
# callbacks fire as output arrives; result is the usual Execution object
print(result.exit_code)
Disconnecting cancels. If your HTTP client goes away before the exit
event — a closed laptop, a dead agent process, Ctrl-C — the platform kills the remote command within a
couple of seconds and records the execution with status: "cancelled" (visible via
GET /v1/executions/{id}). Streaming is therefore also the way to keep a runaway command from
running on your credit: close the stream and the command dies — together with every process it started,
including ones detached with nohup or setsid. They are found by the
SANDBOX_EXEC_TOKEN environment variable, a random value each exec sets for the processes it starts. A plain non-streaming exec is unaffected
by disconnects; it still runs to its timeout_ms boundary.
Three operational notes. The stream emits a : ping comment line at least every 15
seconds, so idle-connection timeouts in proxies do not cut a quiet command off; SSE clients ignore the
comment, and curl -N shows it. The 1 MiB storage cap per stream is unchanged: output past
the cap keeps streaming to you live but is no longer stored, and the stored record and
the terminal exit event then carry truncated: true — what you saw scroll past
is the complete output, what is stored is the first 1 MiB. And if the execution fails after streaming
started, the terminal event is event: error carrying the usual error object instead of an
exit event.
Execution model
Each exec call runs your command as the unprivileged sandbox user in a fresh,
non-login bash, roughly equivalent to:
cd <cwd> && KEY='value' <your command>
The consequences are worth internalising, because they are the most common source of surprise:
- The filesystem persists, the shell does not. Files written by one command are there for the next.
cd,export, shell functions and activated virtualenvs are not. ~/.bashrcis not sourced. Anything a tool's installer appends to your profile (nvm, pyenv, cargo, conda) will not be onPATHin the next call. Use absolute paths or setPATHexplicitly viaenv.- Chain what must share state. Put it in one command with
&&.
# Wrong: the venv is gone by the time the second call runs
{"command": "python3 -m venv .venv && . .venv/bin/activate"}
{"command": "pip install pandas"} # the venv is gone, and PEP 668 refuses this anyway
# Right: one command, or use the venv's own binaries by path
{"command": ". /workspace/.venv/bin/activate && pip install pandas && python3 job.py"}
{"command": "/workspace/.venv/bin/pip install pandas"}
Plain exec calls are not cancelled if your HTTP client disconnects: the platform still waits
for the command up to timeout_ms and records the execution. A streamed
execution behaves the other way round — disconnecting kills the command and records it as
cancelled. To stop runaway work, stream it and drop the connection, or destroy the sandbox.
The sandbox environment
- Working directory
/workspace, owned by thesandboxuser and writable. Also writable:/home/sandboxand/tmp. - Pre-installed: Python 3 (with
pipandvenv), Node.js 22, git, curl, wget, jq, unzip, build-essential, ca-certificates and gnupg. The Python packagesrequests,numpyandpandasare installed during image build; check withpython3 -c "import pandas"before relying on them. - No sudo and no root. The
sandboxuser cannot install system packages withapt, edit files outside its own directories, or read the control plane's files. - Outbound internet is open, so package registries and git remotes work. The cloud metadata endpoint is blocked for the
sandboxuser, and outbound SMTP (ports 25, 465, 587) is blocked account-wide as an anti-abuse measure. - No inbound port is ever opened on the machine. A server you start still binds its own
localhost, exactly as it would on a laptop; to reach it from outside, open a preview URL and the request is carried in over the connection the control plane already holds.
Installing packages
Because there is no sudo, install into user space or a virtualenv. This is Ubuntu 24.04, so the
system Python is marked externally managed and a bare pip install is refused (PEP 668).
Both of these work:
# A virtualenv — preferred when you will run several commands
{"command": "python3 -m venv /workspace/.venv && /workspace/.venv/bin/pip install --quiet pandas",
"timeout_ms": 180000}
{"command": "/workspace/.venv/bin/python job.py"}
# Or install into the user site directly — fine for a one-shot
{"command": "pip install --quiet --break-system-packages --user cowsay && python3 -c \"import cowsay; cowsay.cow('hi')\"",
"timeout_ms": 180000}
# Node.js needs nothing special
{"command": "npm install --no-fund --no-audit lodash && node -e \"console.log(require('lodash').chunk([1,2,3,4],2))\"",
"timeout_ms": 180000}
Installs are slower than they look on a warm laptop cache: a sandbox starts with an empty package
cache every time. Give install commands a generous timeout_ms (120,000–300,000 ms), and
install once per sandbox rather than once per command.
Output limits and truncation
stdout and stderr are each captured up to 1 MiB. Past that
the rest is dropped from the stored record and truncated comes back true —
when streaming, the live stream still carries everything; only what is
stored stops at the cap. Truncation is a lost
result, not an error — the command itself still runs to completion and its exit_code is
accurate.
When output could be large, keep it out of the response:
# Write the full output to a file, return only what you need
{"command": "python3 train.py > /workspace/run.log 2>&1; tail -c 20000 /workspace/run.log"}
# Return structured data instead of logs
{"command": "python3 analyze.py --json > /workspace/out.json && wc -c < /workspace/out.json"}
{"command": "cat /workspace/out.json"}
# Always check the flag
# if result["truncated"]: fetch the file in chunks instead
Timeouts and long-running work
A single command may run for at most 10 minutes (timeout_ms 600,000).
On timeout the process is killed, you get exit_code: 124, and stderr ends with
command timed out after <n>ms — output produced before the kill is still returned.
The sandbox itself is unaffected and stays usable.
For work that outlives one command, run it in the background and poll:
# 1. Start the job detached, writing to a log and a status file.
# Note "cwd" rather than a "cd ... &&" prefix — see the rule below.
{"command": "nohup sh -c 'python3 long_job.py > job.log 2>&1; echo $? > job.done' >/dev/null 2>&1 & echo started",
"cwd": "/workspace",
"timeout_ms": 10000}
# 2. Poll from your own code, every few seconds.
{"command": "cat /workspace/job.done 2>/dev/null || echo running", "timeout_ms": 10000}
# 3. When it reports an exit code, collect the result.
{"command": "tail -c 100000 /workspace/job.log", "timeout_ms": 30000}import time
def run_long_job(sandbox_id, script, poll_seconds=5, max_wait=3600):
"""Start a job detached, poll for completion, return (exit_code, log)."""
run(sandbox_id,
f"nohup sh -c '{script} > job.log 2>&1; echo $? > job.done' "
">/dev/null 2>&1 & echo started",
cwd="/workspace",
timeout_ms=10_000)
deadline = time.time() + max_wait
while time.time() < deadline:
probe = run(sandbox_id, "cat /workspace/job.done 2>/dev/null || echo running",
timeout_ms=10_000)
state = probe["stdout"].strip()
if state != "running":
log = run(sandbox_id, "tail -c 200000 /workspace/job.log", timeout_ms=30_000)
return int(state), log["stdout"]
time.sleep(poll_seconds)
raise TimeoutError("job did not finish in time")
Two things to keep in mind with that pattern: the sandbox still expires on its own schedule, so
extend it while polling if the job may outlast
expires_at; and the polling calls count against your rate limit, so poll every few
seconds, not continuously.
What actually ends an exec call
This is the one rule that surprises people, and it is worth reading before you write your first
background job. The call returns when nothing it started still holds the output stream
open — not when the foreground command exits. A & on its own does not end the
call.
In practice that distinction only bites in one shape, and it is a shape people write constantly:
putting & after a && list. The shell backgrounds the
whole list in a subshell, and that subshell inherits the output stream, so it keeps the call
open even though every redirection inside it points at a file:
# Hangs for the full timeout_ms, then returns exit_code 124.
cd /workspace && nohup python3 -m http.server 8080 > log 2>&1 & # subshell holds the stream
# Returns in about 0.2 s.
nohup python3 -m http.server 8080 > /workspace/log 2>&1 & # simple command, no subshell
Both of those start the server, but only the second one keeps it. When timeout_ms
fires, the call and everything it started are killed — in the first form the detached
server dies with the timeout; the second form returns immediately, the timeout never fires, and the
server survives by design. So the first form is slow and misleading: you wait out
timeout_ms, get exit_code: 124, and the server you started for later is gone
(the &&-subshell holds the call open exactly until the timeout, and whatever it
started is killed with it). If your client raises on a non-zero exit code, though, it will raise
here.
Two ways to write it so the call returns immediately, both shown above in the polling pattern:
- Pass
cwdinstead of prefixingcd … &&. This is whatcwdis for, and it is the tidier fix. - If you need a compound command, redirect the group rather than the parts:
{ cd /workspace && nohup …; } >/dev/null 2>&1 </dev/null &
Neither setsid nor disown is needed. Both are harmless, and neither one
fixes the hang on its own, because the subshell holding the stream is the parent, not the child.
Concurrency inside one sandbox
Nothing stops you from issuing several exec calls against the same sandbox at once, and they will run in parallel on the machine. They share one filesystem and one CPU allowance, so parallel commands that write the same paths will corrupt each other. Prefer one command at a time per sandbox, or give each parallel task its own directory.