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 sandal

sandal 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 execs

sandal.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)
    raise

Full error taxonomy:

ClassHTTPDiscriminantExample values
SandalAuthError401codeinvalid_api_key, expired_token
SandalPermissionError403coderegion_not_enabled, scope_insufficient
SandalNotFoundError404codesandbox_not_found, template_not_found
SandalConflictError409reasontemplate_locked, snapshot_restoring, resource_already_paused, resource_already_running, concurrent_mutation
SandalValidationError422field_errorsper-field messages
SandalQuotaError429resourcesandbox_concurrency, device_concurrency, runner_minutes, api_rate_limit
SandalServerError5xxcodeinternal_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 await differs.
# 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#