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
commandstringRequired, 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.

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 or failed) plus started_at and finished_at.

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"}          # installs into the system python, not the venv

# 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"}

Commands are not automatically cancelled if your HTTP client disconnects: the platform still waits for the command up to timeout_ms and records the execution. To stop runaway work, 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.
  • There is no published address for a sandbox — the API never returns one — so a service you start inside it is reachable only from inside it, on localhost. Sandboxes are for running work, not for hosting endpoints.

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 and truncated comes back true. 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.
{"command": "cd /workspace && nohup sh -c 'python3 long_job.py > job.log 2>&1; echo $? > job.done' >/dev/null 2>&1 & echo started",
 "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"cd /workspace && nohup sh -c '{script} > job.log 2>&1; echo $? > job.done' "
        ">/dev/null 2>&1 & echo started",
        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.

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.