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

FieldTypeDefaultRules
commandstring—Required, non-empty, at most 100,000 characters. Run through bash, so pipes, &&, redirects and heredocs all work.
timeout_msinteger60000Between 1,000 and 600,000 (10 minutes).
cwdstring/workspaceAt most 500 characters. Must exist and be readable by the sandbox user, or the command fails with a shell error.
envobject{}String values only. Keys must match [A-Za-z_][A-Za-z0-9_]*. Applied to this command only.
streambooleanfalseWhen true, the response is a Server-Sent Events stream and output arrives as it is produced. See Streaming output.

Response fields

FieldMeaning
idExecution id (exec_…). Fetch it again later with GET /v1/executions/{id}.
exit_codeProcess exit status. 0 is success; 124 means the command hit timeout_ms and was killed.
stdout / stderrCaptured output, each capped at 1 MiB.
truncatedtrue if either stream hit the cap and output was dropped.
duration_msWall-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 usual exec_… 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, truncated and 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.
  • ~/.bashrc is not sourced. Anything a tool's installer appends to your profile (nvm, pyenv, cargo, conda) will not be on PATH in the next call. Use absolute paths or set PATH explicitly via env.
  • 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 the sandbox user and writable. Also writable: /home/sandbox and /tmp.
  • Pre-installed: Python 3 (with pip and venv), Node.js 22, git, curl, wget, jq, unzip, build-essential, ca-certificates and gnupg. The Python packages requests, numpy and pandas are installed during image build; check with python3 -c "import pandas" before relying on them.
  • No sudo and no root. The sandbox user cannot install system packages with apt, 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 sandbox user, 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 cwd instead of prefixing cd … &&. This is what cwd is 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.