Sandbox SDK
A Python SDK for interacting with Flash Sandbox over HTTP. It talks to the coordinator
(cluster binary) — point host/port (or address) at your coordinator, not at a bare
node (the SDK uses the coordinator's /sandboxes API).
The SDK offers two layers of abstraction:
- Low-level clients (
HTTPClient,AsyncHTTPClient) – manage sandboxes via client methods and returnSandbox/AsyncSandboxobjects fromstart_sandbox, so you can call methods directly on the result. - High-level wrappers (
Sandbox,AsyncSandbox) – bind a single sandbox instance so you never need to pass the ID around.
Installation
pip install flash-sandbox
The package pulls in requests and aiohttp automatically.
Quick Start
Sync usage (recommended for scripts)
HTTPClient.start_sandbox() returns a Sandbox object. You can call methods directly on it, or access the raw ID via .id:
from flash_sandbox import HTTPClient
client = HTTPClient(host="localhost", port=8080)
# start_sandbox returns a Sandbox object.
# No `type` → the cluster picks any available backend (Docker, Kubernetes, …).
# Pass type="docker" (etc.) to force a specific backend.
sandbox = client.start_sandbox(
image="alpine:latest",
command=["tail", "-f", "/dev/null"],
memory_mb=128,
cpu_cores=0.5,
)
print(f"Sandbox ID: {sandbox.id}")
# Call methods directly on the sandbox — no need to pass an ID
print(f"Status: {sandbox.get_status()}")
result = sandbox.exec_command(["echo", "Hello!"])
print(f"Output: {result.stdout.strip()}")
print(f"Exit: {result.exit_code}, server time: {result.duration_ms:.1f}ms")
# Optional `metadata` is forwarded uninterpreted to the exec trace /
# event log so execs can be classified downstream (see "Classifying
# execs" below). The backend ignores it; it doesn't change execution.
result = sandbox.exec_command(
["echo", "verified"],
metadata={"query_type": "verification", "task_id": 7},
)
py_out = sandbox.run_python("print('Hello Python in Sandbox')")
print(f"Python Output: {py_out.strip()}")
details = sandbox.get_info()
print(f"Scheduled on: {details.node_id} ({details.backend})")
plat_info = sandbox.get_platform_info()
print(f"Platform: {plat_info['system']} {plat_info.get('release', '')}")
metrics = sandbox.get_metrics()
print(f"Memory: {metrics.memory_usage_bytes} / {metrics.memory_limit_bytes}")
print(f"CPU: {metrics.cpu_percent}%")
snap = sandbox.snapshot()
print(f"Snapshot: {snap.snapshot_path}")
sandbox.resume()
sandbox.write_file("/tmp/hello.txt", b"Hello from SDK!")
content = sandbox.read_file("/tmp/hello.txt")
print(f"File Content: {content.decode()}")
sandbox.stop() # cleanup=True by default: removes the container
# sandbox.stop(cleanup=False) # stop without removing resources
client.close()
Pre-warm a future batch with a claimable ticket:
ticket = client.demand(
"trainer",
"batch-43",
image="python:3.11-slim",
backend="libkrun",
expect=next_batch_at, # RFC3339 string or timezone-aware datetime
count=8,
cpu=2,
memory=1024,
)
sandbox = client.claim(ticket)
cleanup = client.cancel(ticket) # cleans up anything still unclaimed
claim(ticket) waits for one prepared sandbox, returns a normal Sandbox, and
can be repeated while ticket.remaining_claims is nonzero. Retrying with the
same live ticket safely replays an ambiguous claim. Use the lower-level
claim_warm_demand(..., claim_id=...) API when the claim ID must survive a
client process restart. The async client exposes the same methods with await.
Cancelling a ticket does not stop sandboxes that were already claimed.
Use as a context manager for automatic cleanup:
from flash_sandbox import HTTPClient, Sandbox
client = HTTPClient(host="localhost", port=8080)
with Sandbox.create(
client,
type="docker",
image="alpine:latest",
command=["tail", "-f", "/dev/null"],
) as sandbox:
result = sandbox.exec_command(["echo", "Hello!"])
print(result.stdout.strip())
# sandbox.stop() is called automatically on exit
client.close()
You can also wrap an existing sandbox ID:
from flash_sandbox import HTTPClient, Sandbox
client = HTTPClient(host="localhost")
sandbox = Sandbox("existing-sandbox-id", client)
print(sandbox.get_status())
Async usage
AsyncHTTPClient.start_sandbox() returns an AsyncSandbox object. Call await on its methods:
import asyncio
from flash_sandbox import AsyncHTTPClient
async def main():
client = AsyncHTTPClient(host="localhost", port=8080)
# start_sandbox returns an AsyncSandbox object
sandbox = await client.start_sandbox(
type="docker",
image="alpine:latest",
command=["tail", "-f", "/dev/null"],
)
print(f"Sandbox ID: {sandbox.id}")
result = await sandbox.exec_command(["echo", "Hello from Async!"])
print(f"Output: {result.stdout.strip()}")
await sandbox.write_file("/tmp/async.txt", b"Async hello!")
content = await sandbox.read_file("/tmp/async.txt")
print(f"Async File Content: {content.decode()}")
await sandbox.stop()
await client.close()
asyncio.run(main())
Class-based pattern
When the client lives as an instance attribute and sandbox start/stop happen across different methods:
from flash_sandbox import AsyncHTTPClient
class MyAgent:
def __init__(self, orchestrator_host: str = "localhost"):
self.sandbox_client = AsyncHTTPClient(host=orchestrator_host, port=8080)
async def forward(self, prompt: str, metadata: dict):
# start_sandbox returns an AsyncSandbox object
sandbox = await self.sandbox_client.start_sandbox(
type="docker",
image=metadata["image_name"],
command=["tail", "-f", "/dev/null"],
)
result = await sandbox.exec_command(["echo", "Hello!"])
print(f"Output: {result.stdout.strip()}")
platform_info = await sandbox.get_platform_info()
print(f"Platform: {platform_info['system']} {platform_info.get('release', '')}")
await sandbox.stop()
async def close(self):
await self.sandbox_client.close()
Context manager pattern
Use async with for automatic cleanup:
import asyncio
from flash_sandbox import AsyncHTTPClient, AsyncSandbox
async def main():
async with AsyncHTTPClient(host="localhost", port=8080) as client:
async with await AsyncSandbox.create(
client,
type="docker",
image="alpine:latest",
command=["tail", "-f", "/dev/null"],
) as sandbox:
status = await sandbox.get_status()
print(f"Status: {status}")
result = await sandbox.exec_command(["echo", "Hello!"])
print(f"Output: {result.stdout.strip()}")
# sandbox.stop() is called automatically
asyncio.run(main())
API Reference
Sandbox / AsyncSandbox (high-level)
The Sandbox class wraps a sandbox ID and a client, exposing all operations as simple method calls. AsyncSandbox is the async equivalent — all I/O methods are coroutines.
Both work with HTTPClient and AsyncHTTPClient.
Construction
# HTTP clients return Sandbox / AsyncSandbox directly from start_sandbox
sandbox = client.start_sandbox(type="docker", image="alpine:latest", ...)
sandbox = await async_client.start_sandbox(type="docker", image="alpine:latest", ...)
# Or wrap an existing sandbox ID (works with any client)
sandbox = Sandbox(sandbox_id, client)
sandbox = AsyncSandbox(sandbox_id, async_client)
# Or use the create() factory (calls start_sandbox, then wraps if needed)
sandbox = Sandbox.create(client, type="docker", image="alpine:latest", ...)
sandbox = await AsyncSandbox.create(async_client, type="docker", image="alpine:latest", ...)
Methods
| Method | Description |
|---|---|
get_status() |
Return the status string ("running", "stopped", etc.). |
get_info() / describe() |
Return SandboxDetails with status, node/backend placement, image, resources, TTL, and creation metadata when the coordinator provides it. |
exec_command(command, *, metadata=None, stream=False) |
Execute a command. Returns ExecResult with stdout, stderr, exit_code, client elapsed_ms, server duration_ms when available, prepared command, and byte-count helpers. metadata is an optional caller bag forwarded to the exec trace / event log for downstream classification; the backend ignores it. |
run_python(code) |
Execute Python code inside the sandbox. Returns stdout. |
run_python_result(code) |
Execute Python code and return PythonResult with result text plus stdout/stderr/exit metadata when available. |
get_platform_info() |
Get platform information. Returns a dict with keys like system, node, release, etc. |
get_metrics() |
Return point-in-time resource-usage metrics. |
read_file(path) |
Read a file from the sandbox as bytes. |
read_file_result(path) |
Read a file and return ReadFileResult with path, content, size, and optional stat metadata. |
write_file(path, content) |
Write bytes to a path in the sandbox. |
write_file_result(path, content) |
Write bytes and return WriteFileResult with bytes-written acknowledgement. |
agent_health_detail() |
Probe the in-sandbox agent and return status, detail, and latency. |
snapshot() |
Create a snapshot. Returns SnapshotResult with paths, owning snapshot node ID, and size_bytes when available. |
resume() |
Resume a paused / snapshotted sandbox. |
resume_result() |
Resume and return a lifecycle acknowledgement. |
stop(cleanup=True) |
Stop the sandbox. When cleanup=True (default), backing resources are removed. Pass cleanup=False to stop without removing resources. |
stop_result(cleanup=True) |
Stop and return a lifecycle acknowledgement. |
Properties
| Property | Description |
|---|---|
id |
The raw sandbox ID string. |
details |
Cached SandboxDetails from creation or the last get_info() call, when available. |
Context manager
Sandbox supports with and AsyncSandbox supports async with. The sandbox is automatically stopped when the block exits:
with Sandbox.create(client, type="docker", image="alpine:latest") as sandbox:
sandbox.exec_command(["echo", "hi"])
# sandbox.stop(cleanup=True) is called automatically on exit
async with await AsyncSandbox.create(client, type="docker", image="alpine:latest") as sandbox:
await sandbox.exec_command(["echo", "hi"])
# await sandbox.stop(cleanup=True) is called automatically on exit
Low-level client methods
HTTPClient and AsyncHTTPClient expose the same set of methods. AsyncHTTPClient methods must be awaited.
SandboxID — flexible sandbox identifiers
Every method that accepts a sandbox_id parameter uses the SandboxID type, which is Union[str, Sandbox, AsyncSandbox]. This means you can pass either a plain string ID or a Sandbox / AsyncSandbox object — they are fully interchangeable:
sandbox = client.start_sandbox(type="docker", image="alpine:latest")
# All three forms are equivalent:
client.get_status(sandbox) # pass the Sandbox object
client.get_status(sandbox.id) # pass the .id string
client.get_status("sb-abc123") # pass any string ID
This makes it easy to mix high-level wrapper usage with low-level client calls during migration or when you need both styles in the same codebase.
| Method | Description |
|---|---|
start_sandbox(image, command, memory_mb, cpu_cores, type="", ...) |
Start a new sandbox. Returns a Sandbox/AsyncSandbox. type is optional — omit it (or pass "auto") to let the cluster pick any available backend; set it ("docker", "kubernetes", …) to force one. |
stop_sandbox(sandbox_id, cleanup=True) |
Stop a sandbox. When cleanup=True (default), backing resources are removed. Pass cleanup=False to keep resources for inspection. Accepts SandboxID. |
exec_command(sandbox_id, command, *, metadata=None, stream=False) |
Execute a command in a sandbox. Returns an object with stdout, stderr, and exit_code. metadata is an optional caller bag forwarded to the exec trace / event log. Accepts SandboxID. |
get_status(sandbox_id) |
Return the status string ("running", "stopped", etc.). Accepts SandboxID. |
get_metrics(sandbox_id) |
Return point-in-time resource-usage metrics (memory, CPU, network, block I/O). Accepts SandboxID. |
snapshot_sandbox(sandbox_id) |
Create a snapshot. Returns a SnapshotResult. Accepts SandboxID. |
resume_sandbox(sandbox_id) |
Resume a paused / snapshotted sandbox. Accepts SandboxID. |
run_python(sandbox_id, code) |
Execute arbitrary Python code inside the sandbox. Returns the stdout output. Accepts SandboxID. |
get_platform_info(sandbox_id) |
Get platform information from the sandbox. Returns a dict with platform data. Accepts SandboxID. |
read_file(sandbox_id, path) |
Read a file from the sandbox as bytes. Accepts SandboxID. |
write_file(sandbox_id, path, content) |
Write bytes to a path in the sandbox. Accepts SandboxID. |
close() |
Release the underlying connection / session. |
Constructor options
HTTPClient
HTTPClient(
host="localhost", # Coordinator hostname — or a full URL (see below)
port=8080, # Coordinator HTTP port (default 8080)
address=None, # Full URL, overrides host/port (e.g. "http://proxy:9090/v1/service/sandbox")
timeout=30.0, # Request timeout in seconds (None = no timeout)
session=None, # Optional requests.Session for custom TLS / auth / retries
api_key=None, # Sent as "Authorization: Bearer <key>"; defaults to $FLASH_API_KEY
)
AsyncHTTPClient
AsyncHTTPClient(
host="localhost", # Coordinator hostname — or a full URL (see below)
port=8080, # Coordinator HTTP port (default 8080)
address=None, # Full URL, overrides host/port
timeout=30.0, # Request timeout in seconds
session=None, # Optional aiohttp.ClientSession
api_key=None, # Sent as "Authorization: Bearer <key>"; defaults to $FLASH_API_KEY
)
Connecting with just a URL
The first positional argument accepts a full URL, so a hosted coordinator needs nothing else:
client = HTTPClient("https://sandbox.example.com")
client = HTTPClient("localhost:8080")
client = HTTPClient("http://proxy:9090/v1/service/sandbox")
A value is treated as a URL when it carries a scheme, a path, a bracketed IPv6
authority, or an explicit port; a bare hostname (HTTPClient("localhost"))
still pairs with port. When the first argument is a URL, port is ignored —
the URL is the single source of truth. The explicit address= keyword keeps
working and wins over host.
Authentication
Against a coordinator started with --api-key, pass the key as api_key (or
set $FLASH_API_KEY); it is sent as Authorization: Bearer <key> on every
request:
client = HTTPClient("https://sandbox.example.com", api_key="s3cret")
The orchestrator also accepts the key as an X-API-Key header, which is what
the admin tooling under meta/tools/ uses.
HTTPClient-only extras
The HTTP transport includes a few additional features:
- Firecracker fields on
start_sandbox:kernel_image,initrd_path,snapshot_path,mem_file_path. - Custom timeouts per-client via the
timeoutparameter. - Session injection – pass your own
requests.Sessionfor connection pooling, mutual TLS, retry policies, or authentication headers. - Typed exceptions:
SandboxHTTPError(with.status_codeand.detail) and the more specificSandboxNotFoundErrorfor 404 responses. - Auto-expiry via
ttl_secondsonstart_sandbox— see below.
Auto-expiry (ttl_seconds)
Pass ttl_seconds=N to start_sandbox to cap the sandbox's wall-clock lifetime. The node-side reaper deletes the sandbox once the deadline passes, even if the caller exits without calling stop_sandbox. Useful as a safety net for batch jobs and benchmarks that can leak sandboxes on crash.
# Sandbox is auto-deleted ~10 minutes after creation, even if this
# process is killed before reaching stop().
sandbox = client.start_sandbox(
type="kubernetes",
image="alpine:latest",
ttl_seconds=600,
)
Currently enforced by the kubernetes backend only; other backends accept the field and silently ignore it. The deadline is stamped on the pod as the flash-sandbox/ttl-expires-at annotation, so it survives orchestrator restarts.
Classifying execs (metadata)
exec_command (sync and async, on both HTTPClient/AsyncHTTPClient and
Sandbox/AsyncSandbox) accepts an optional metadata mapping. It is
opaque to the backend — backends ignore it and never change the command based
on it — and is forwarded verbatim onto the exec trace (.jsonl, on the
start and finish events), the structured event log (container_execs,
under the client key), and the raw request body captured by FSB_DEBUG.
This lets you classify execs downstream (e.g. interactive vs.
verification, by task id) without encoding semantics into the command:
# verification query
sb.exec_command(["pytest", "-x"],
metadata={"query_type": "verification", "task_id": 7})
# interactive query
sb.exec_command(["git", "status"],
metadata={"query_type": "interactive", "task_id": 7})
# async is identical
await sb.exec_command(["pytest"],
metadata={"query_type": "verification", "task_id": 7})
# streaming variant honors metadata too
for frame in sb.exec_command(["pytest", "-v"], stream=True,
metadata={"query_type": "verification"}):
...
Values must be JSON-serializable. Omit metadata (the default) to keep the
wire body minimal — existing calls are byte-identical to before, so this is
fully backwards-compatible. To attribute CPU to a query type by bracketing an
exec with get_metrics() samples, see
docs/guides/exec-cpu-attribution.md.
Receiving metadata in Go
On the server side, metadata is a plain map[string]any that flows through
four layers. None of them interpret it — the field is read from the request
and forwarded onto the downstream captures.
| Layer | Type / location | Notes |
|---|---|---|
| HTTP request | api.ExecRequest.Metadata (api/handlers.go) |
Decoded from the JSON body; json:"metadata,omitempty". |
| Backend call | pebble.ExecOpts.Metadata (internal/pebble/sandbox.go) |
Passed into Manager.Exec(...) / Sandbox.Exec(...); backends MUST NOT interpret it. |
Coordinator request DB (FSB_DEBUG) |
api_calls.metadata (api/request_log_sqlite.go) |
The top-level metadata object is compacted into a queryable column (no BLOB parse needed). |
| Structured event log | metadata["client"] in Manager.exec (internal/pebble/manager.go) |
The container_execs event recorder writes the bag under client. |
To add a new exec-like endpoint that also receives metadata, decode the same
field into a pebble.ExecOpts and pass it to Manager.Exec:
var req struct {
Command []string `json:"command"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// ... decode req ...
res, err := m.Exec(ctx, id, req.Command, "my_op",
pebble.ExecOpts{Metadata: req.Metadata})
For execs dispatched outside Manager.Exec (the streaming path), the
coordinator records the same bag: Manager.RecordExecTerminal(id, metadata, valid) applies RLVR terminal-outcome accounting, and the per-exec metadata
lands in the coordinator's api_calls.metadata column when FSB_DEBUG=1
(the former node-side TraceExecStart JSONL trace has been removed).
Response types (HTTP client)
| Class | Fields |
|---|---|
ExecResult |
stdout: str, stderr: str, exit_code: int |
MetricsResult |
memory_usage_bytes, memory_limit_bytes, memory_percent, cpu_percent, pids_current, net_rx_bytes, net_tx_bytes, block_read_bytes, block_write_bytes |
SnapshotResult |
snapshot_path: str, mem_file_path: str, node_id: str |
All response dataclasses are frozen (immutable).
Context manager
All clients support the context-manager protocol (with for sync, async with for async):
from flash_sandbox import HTTPClient
with HTTPClient(host="localhost") as client:
sandbox = client.start_sandbox(type="docker", image="alpine:latest")
sandbox.exec_command(["echo", "hello"])
sandbox.stop()
# Connection is automatically closed here.
Proxy / reverse-proxy support
Both clients support routing through a reverse proxy that uses path-based routing. Pass the full address including the path prefix:
http_client = HTTPClient(address="http://proxy.example.com:8092/v1/service/sandbox")
The Sandbox / AsyncSandbox wrappers inherit proxy support from whichever client you pass in.
Running tests
pip install -e ".[dev]"
pytest tests/
CLI (fsb)
Installing flash-sandbox also installs the fsb command.
pip install flash-sandbox
Point it at a coordinator with -H/--address or the FLASH_SANDBOX_ADDR env var
(default localhost:8080). Like the SDK, fsb uses the coordinator's /sandboxes API,
not a bare node's /native/*:
export FLASH_SANDBOX_ADDR=coordinator.example:8080
fsb ps # list running sandboxes
fsb inspect sb-a1b2c3 # status + platform info
fsb nodes # cluster nodes
fsb templates # registered templates
fsb status # cluster summary
fsb create --template python # create from a template (or --image IMG)
fsb stop sb-a1b2c3
fsb snapshot sb-a1b2c3
fsb resume sb-a1b2c3
fsb exec sb-a1b2c3 -- ls -la /workspace # exits with the remote exit code
fsb metrics sb-a1b2c3
fsb cp ./local.txt sb-a1b2c3:/tmp/x # upload
fsb cp sb-a1b2c3:/tmp/x ./out.txt # download
Add --json (before the subcommand) for machine-readable output:
fsb --json ps | jq '.[].id'
fsb speaks the coordinator's HTTP API only. For host-level containerd
administration on a node (images/snapshots/content cleanup), use the
containerd CLI (ctr) or the Docker CLI directly.
Metadata
Release files for flash-sandbox 0.2.12
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| flash_sandbox-0.2.12.tar.gz | 113.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| flash_sandbox-0.2.12-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 181.6 kB
Release files / flash_sandbox-0.2.12.tar.gz
| Download URL | flash_sandbox-0.2.12.tar.gz |
|---|---|
| Size | 113.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
21a6a2c56ce2ee4b6958992ae3ded42bd4eee0eff0ed60f778bf49d1faa99f1b
|
|
BLAKE2b-256 checksum How to use checksums |
b26bda8a72b11667a60d95c84b7575cc99b440e3808a59fdb8538de00e2eab8c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.13
|
Release files / flash_sandbox-0.2.12-py3-none-any.whl
| Download URL | flash_sandbox-0.2.12-py3-none-any.whl |
|---|---|
| Size | 67.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3662a33149f3cc937310f4029fce52d87a2c7b81f2fd13fe6ac6e25d9fc08322
|
|
BLAKE2b-256 checksum How to use checksums |
4c30133969a6646a97f390025966080e63024964ae716f0f31094345d0b35301
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.13
|