Sandbox API (Python)¶
Sandbox(...)¶
Creating a Sandbox provisions a real microVM and blocks until it boots.
Sandbox(
*,
client=None, # reuse a Client (advanced)
api_key=None, # defaults to SISTEMO_API_KEY
base_url=None, # defaults to SISTEMO_BASE_URL or https://api.sistemo.io
name="", # optional label
vcpus=1,
memory_mb=1024,
rootfs_size_gb=10,
stack="base", # base | python | node | ai | debian13 | ubuntu26.04
metadata=None, # dict of arbitrary key/values
root_volume_id="", # boot an existing detached root disk
idempotency_key=None, # defaults to a fresh UUID
)
| Argument | Default | Notes |
|---|---|---|
api_key | SISTEMO_API_KEY | Must be a full-scope key to create/run |
base_url | https://api.sistemo.io | Override the API endpoint |
vcpus | 1 | Virtual CPUs |
memory_mb | 1024 | RAM in MB |
rootfs_size_gb | 10 | Root disk in GB |
stack | "base" | Base image; see Images |
metadata | {} | Stored with the machine |
root_volume_id | "" | Boot a disk kept by an earlier close(preserve_storage=True). Size and stack come from the volume, so the rootfs_size_gb and stack arguments are ignored |
idempotency_key | random UUID | See below |
Attributes: sb.id (machine UUID), sb.state (e.g. "running"), sb.root_volume_id.
The defaults fit the free quota
1 vCPU / 1024 MB / 10 GB fits a free account (up to 2 vCPU / 2 GB per machine, 3 machines). That is what keeps a bare Sandbox() working on a new account — and it means raising a default breaks first-time users unless their quota is raised too.
sb.run(script, timeout=30)¶
Run a shell command/script inside the sandbox.
Returns an ExecResult:
| Field | Type | Meaning |
|---|---|---|
exit_code | int | Process exit code (-1 if unknown) |
stdout | str | Standard output |
stderr | str | Standard error |
ok | bool | True when exit_code == 0 |
timeout is seconds (default 120, ceiling 24 hours), enforced in the machine. This is start + wait on POST /execs. For streaming or reconnect use start() — long-running.
sb.start()¶
Start a command that outlives this request. Returns a Job immediately.
job = sb.start("npm ci && npm run build", timeout=3600)
for chunk in job.stream():
print(chunk, end="")
result = job.wait()
print(result.state, result.exit_code)
timeout is the job's lifetime in the machine (default 1 hour, max 24). The SDK mints exec_id if you omit it — pass your own to reconnect after a crash.
Job | |
|---|---|
id / state / exit_code | Handle, status, code (None until known) |
done / ok | Terminal? Exited 0? |
truncated | Output hit the 32 MiB cap |
wait(timeout=None) | Block until terminal. Client timeout does not cancel the job |
stream(stream="stdout") | Yield output as it appears |
output(stream="stdout") | Bytes so far |
cancel() | SIGTERM, then SIGKILL |
sb.job(id) / sb.jobs() | Re-attach / list |
If start raises ExecStartUnconfirmed, the command may be running. The exception carries exec_id — poll sb.job(...), do not start again.
sb.close(preserve_storage=False)¶
Destroy the sandbox. Pass preserve_storage=True to keep its disk.
Context manager¶
Idempotency — why a retry cannot double-boot¶
Creating a machine sends an Idempotency-Key header. If the response is lost to a dropped connection, the retry returns the machine that was already created instead of booting a second one you would be billed for and never see.
The SDK generates a fresh UUID per Sandbox() call, which is right for almost everyone. Pass your own only when your code may re-enter the same logical create — for example a job runner that retries a task:
Timeouts and retries¶
from sistemo import Client, Sandbox
c = Client(
timeout=120.0, # per-request seconds; create blocks until boot
max_retries=2, # extra attempts after the first → up to 3 tries
retry_backoff=0.4, # base backoff seconds, exponential + jitter
retry_max_backoff=8.0,
)
sb = Sandbox(client=c)
What gets retried:
| Retried on 408/429/5xx? | Retried on a dropped connection? | |
|---|---|---|
GET, DELETE | Yes | Yes |
POST with an idempotency key (create) | Yes | Yes |
POST without one — including run() | No | Yes |
run() is never retried on an HTTP error, on purpose
A replayed exec runs your script a second time. Since the SDK cannot know whether your script is safe to repeat, it does not decide for you: an HTTP error from run() is yours to handle. Connection failures before a response are still retried, because nothing ran.
Low-level Client (advanced)¶
For endpoints the Sandbox helper doesn't cover — stop, start, volumes, snapshots, quotas, billing, the Activity feed — use the raw client:
from sistemo import Client
c = Client() # reads SISTEMO_API_KEY / SISTEMO_BASE_URL
machines = c.request("GET", "/v1/machines")
c.request("POST", f"/v1/machines/{mid}/stop")
c.request("POST", f"/v1/machines/{mid}/start")
quotas = c.request("GET", "/v1/quotas")
activity = c.request("GET", "/v1/activity?category=machine")
Signature:
Client(api_key=None, base_url=None, timeout=120.0, *,
max_retries=2, retry_backoff=0.4, retry_max_backoff=8.0)
c.request(method, path, body=None, *, headers=None,
idempotency_key=None, max_retries=None)
Pass idempotency_key= on a POST you may safely repeat, and max_retries=0 to disable retries for a single call. See the API reference for paths and response shapes.
Volume helpers¶
Client wraps the volume endpoints so you do not have to hand-write the paths:
from sistemo import Client
c = Client()
volumes = c.list_volumes() # list[dict] — [] when you have none
vol = c.create_volume("agent-data", 10) # name, size_gb
c.attach_volume(machine_id, vol["id"]) # machine must be STOPPED
c.detach_volume(machine_id, vol["id"])
c.delete_volume(vol["id"])
| Method | Returns |
|---|---|
list_volumes() | list[dict] — root and data volumes |
create_volume(name, size_gb) | dict — the new volume |
attach_volume(machine_id, volume_id) | dict |
detach_volume(machine_id, volume_id) | dict |
delete_volume(volume_id) | dict |
create_volume, attach_volume and detach_volume each send their own Idempotency-Key, so a dropped connection cannot create or move a volume twice.
list_volumes() returns a list here, an object in JS
Python gives you list[dict] directly; @sistemo/sdk gives you { volumes: Volume[] }. Both normalise the API's null to empty, so neither ever hands you None/undefined — but the unwrapping differs, and that is the line to change when porting an example across.
A detached root volume can be booted onto a new sandbox with root_volume_id — that is how you keep a disk and come back to it. See Persist data with volumes.