Python SDK
Install and use the sandal package to create sandboxes, devices, and CI runners, sync or async, from any Python 3.9+ runtime.
Install#
pip install sandal# or
uv add sandal
poetry add sandalsandal ships fully typed (PEP 561), with a synchronous Sandal client and
an async AsyncSandal client sharing the same surface.
Initialize the client#
import os
from sandal import Sandal
sandal = Sandal(
api_key=os.environ["SANDAL_API_KEY"], # sk_live_... or sk_test_...
region="us", # "us" | "eu", defaults to your account's home region
)Async variant:
import os
from sandal import AsyncSandal
sandal = AsyncSandal(api_key=os.environ["SANDAL_API_KEY"], region="eu")Client options#
Sandal(
api_key: str, # required
region: str = None, # "us" | "eu", default: account default
base_url: str = None, # override for self-hosted / proxy setups
timeout: float = 30.0, # seconds
max_retries: int = 2, # exponential backoff on 429/5xx
)Namespaces#
sandal.sandboxes # Firecracker/Cloud Hypervisor microVMs
sandal.devices # iOS/Android simulators
sandal.runners # GitHub Actions runner pools
sandal.builds # code-interpreter style one-shot execssandal.sandboxes, sync#
sb = sandal.sandboxes.create(
template="python-3.12",
cpu=2,
memory_mb=2048,
timeout_ms=15 * 60_000, # auto-pause after 15 min idle
metadata={"user_id": "u_123"},
)
# Run a command and stream output
result = sb.exec(
"pytest -q",
cwd="/workspace",
env={"CI": "true"},
on_stdout=lambda chunk: print(chunk, end=""),
on_stderr=lambda chunk: print(chunk, end=""),
)
print(result.exit_code, result.duration_ms)
# Filesystem
sb.fs.write("/workspace/main.py", "print('hi')")
contents = sb.fs.read("/workspace/main.py")
sb.fs.mkdir("/workspace/data")
entries = sb.fs.ls("/workspace")
# Expose a running dev server publicly
url = sb.expose(8000) # -> https://8000-sb_xxxx.sandal.dev
# Lifecycle: pause = $0 billed, resume in <200ms when warm
sb.pause()
sb.resume()
# Fork a running sandbox (copy-on-write, near-instant)
child = sb.fork()
# Snapshots, per-minute incremental, encrypted
snap = sb.snapshot.create(label="post-install")
restored = sandal.sandboxes.create_from_snapshot(snap.id)
sb.kill()sandal.sandboxes, async#
import asyncio
from sandal import AsyncSandal
async def main():
sandal = AsyncSandal(api_key=os.environ["SANDAL_API_KEY"])
sb = await sandal.sandboxes.create(template="python-3.12")
result = await sb.exec("pytest -q", cwd="/workspace")
print(result.exit_code)
await sb.fs.write("/workspace/main.py", "print('hi')")
url = await sb.expose(8000)
await sb.pause()
await sb.kill()
asyncio.run(main())You can also stream stdout as an async iterator instead of a callback:
async for chunk in sb.exec_stream("pytest -q"):
print(chunk, end="")sandal.devices#
dev = sandal.devices.create(
kind="ios-simulator", # "ios-simulator" | "android-emulator"
device_model="iphone-16-pro",
os_version="18.2",
)
dev.install_app("./build/MyApp.ipa")
dev.launch_app("com.example.myapp")
dev.tap(180, 420)
dev.swipe(from_=(200, 600), to=(200, 200))
dev.type_text("hello@sandal.dev")
png_bytes = dev.screenshot()
# Appium / ADB tunnels for existing test suites
adb_url = dev.tunnels.adb()
wda_url = dev.tunnels.appium()
dev.terminate()Async:
dev = await sandal.devices.create(kind="android-emulator", device_model="pixel-9")
await dev.install_app("./build/app-debug.apk")
await dev.tap(240, 800)
frame = await dev.screenshot()
await dev.terminate()sandal.runners#
pool = sandal.runners.pools.create(
os="macos", # "linux" | "windows" | "macos"
labels=["sandal-macos"],
min_warm=2, # keep 2 warm for near-instant job pickup
max_concurrency=10,
)
# In your workflow YAML:
# runs-on: sandal-ubuntu-24.04
jobs = sandal.runners.jobs.list(pool_id=pool.id, status="running")
sandal.runners.pools.update(pool.id, min_warm=4)
sandal.runners.pools.delete(pool.id)sandal.builds#
One-shot, stateless code execution, the code-interpreter surface.
build = sandal.builds.run(
language="python",
code="""
import pandas as pd
df = pd.read_csv("data.csv")
print(df.describe())
""",
files=[{"path": "data.csv", "content": csv_string}],
)
print(build.stdout)
print(build.artifacts) # generated files, plots, etc.Helpers#
wait_until_ready#
Polls a sandbox/device until it reports running, or raises on failed.
sb = sandal.sandboxes.create(template="python-3.12")
sandal.wait_until_ready(sb, timeout_ms=10_000)
# async
await sandal.wait_until_ready(sb, timeout_ms=10_000)stream_events#
Subscribe to lifecycle + billing events over WebSocket. Sync version returns an unsubscribe callable; async version is an async iterator.
def on_event(event):
if event.type in ("sandbox.paused", "sandbox.resumed", "sandbox.exec.completed"):
print(event)
unsubscribe = sandal.stream_events({"resource_id": sb.id}, on_event)
# later
unsubscribe()# async iterator form
async for event in sandal.stream_events(resource_id=sb.id):
print(event.type, event.data)exec_command#
A standalone helper when you just want "run this and get output" without juggling the sandbox object.
from sandal import exec_command
result = exec_command(sandal, sb.id, "pytest -q")
print(result.stdout, result.exit_code)
# async
result = await exec_command(sandal, sb.id, "pytest -q")Error model#
All SDK errors subclass SandalError and are discriminated by
error.status and error.code.
from sandal import (
SandalError,
SandalConflictError,
SandalQuotaError,
)
try:
sandal.sandboxes.create(template="python-3.12")
except SandalConflictError as err:
# HTTP 409, typed reasons
match err.reason:
case "template_locked":
print("Template is currently being built, retry shortly")
case "snapshot_restoring":
print("Snapshot restore already in progress")
case "resource_already_paused" | "resource_already_running":
print(f"Sandbox is already {err.reason.rsplit('_', 1)[-1]}")
case "concurrent_mutation":
print("Another request modified this resource concurrently")
except SandalQuotaError as err:
# HTTP 429, typed quota resource + reset time
match err.resource:
case "sandbox_concurrency":
print(f"Max concurrent sandboxes reached, resets {err.resets_at}")
case "device_concurrency":
print("Max concurrent simulators reached")
case "runner_minutes":
print("Monthly runner-minute quota exhausted")
case "api_rate_limit":
print(f"Rate limited, retry after {err.retry_after_ms}ms")
except SandalError as err:
print(err.status, err.code, err.message)
raiseFull error taxonomy:
| Class | HTTP | Discriminant | Example values |
|---|---|---|---|
SandalAuthError | 401 | code | invalid_api_key, expired_token |
SandalPermissionError | 403 | code | region_not_enabled, scope_insufficient |
SandalNotFoundError | 404 | code | sandbox_not_found, template_not_found |
SandalConflictError | 409 | reason | template_locked, snapshot_restoring, resource_already_paused, resource_already_running, concurrent_mutation |
SandalValidationError | 422 | field_errors | per-field messages |
SandalQuotaError | 429 | resource | sandbox_concurrency, device_concurrency, runner_minutes, api_rate_limit |
SandalServerError | 5xx | code | internal_error, region_unavailable |
Sync vs. async, which to use#
- Scripts, notebooks, CI steps, Django views: use
Sandal(sync). - FastAPI, aiohttp, asyncio-based agents/orchestrators: use
AsyncSandal. - Both clients are generated from the same OpenAPI spec, so method names,
arguments, and return types are identical, only
awaitdiffers.
# Sync
from sandal import Sandal
sandal = Sandal(api_key="sk_live_...")
sb = sandal.sandboxes.create(template="node-24")
# Async
from sandal import AsyncSandal
sandal = AsyncSandal(api_key="sk_live_...")
sb = await sandal.sandboxes.create(template="node-24")Next steps#
- TypeScript SDK, the same surface,
camelCase - CLI reference,
sandal init,sandal exec,sandal runners - MCP server, give any MCP-capable agent direct sandbox access