Python SDK

Python SDK reference

The orkestr Python package is the official client for the sandbox API. Sync only in v1 (an async AsyncSandbox is planned). Requires Python 3.10 or newer.

Get started
Sandboxes are free to start. Install with pip install orkestr, then enable them in one click from the Sandboxes console and mint a scoped token.

Install

terminal
pip install orkestr

Authentication

Every request is authenticated with a Bearer API token. The SDK looks for it in three places, in order:

  • The api_key= kwarg on Sandbox.create() / Sandbox.get() / etc.
  • The client= kwarg (a pre-built OrkestrClient)
  • The ORKESTR_API_KEY environment variable
auth.py
import os
from orkestr import Sandbox

# 1) Implicit: SDK reads ORKESTR_API_KEY from env
sbx = Sandbox.create(template="python-3.12")

# 2) Explicit: per-call api_key
sbx = Sandbox.create(template="python-3.12", api_key="ork_...")

# 3) Reusable client (good for long-running agents)
from orkestr import OrkestrClient
client = OrkestrClient(api_key="ork_...", base_url="https://api.orkestr.eu")
sbx = Sandbox.create(template="python-3.12", client=client)
Scoped tokens
Mint tokens scoped only to sandboxes:read and sandboxes:write for agent runtimes. A scoped token cannot reach the rest of the orkestr platform if it leaks.

Sandbox class

Sandbox is the main public class. Static constructors create / fetch / list / resume sandboxes; instance methods drive exec, files, and lifecycle.

Sandbox.create

python
from orkestr import Sandbox

sbx = Sandbox.create(
    template="python-3.12",
    size="small",
    network="off",
    timeout_seconds=600,
    env={"OPENAI_API_KEY": "sk-..."},
    metadata={"agent_run": "r_42"},
    region="fsn1",
)
print(sbx.id, sbx.status)
ParameterTypeDescription
template requiredTemplateA built-in - python-3.12, python-3.12-bare, node-22, debian-12, code-interpreter - or a custom template id (tmpl_...) with your dependencies preinstalled.
sizeSandboxSizeFixed size: small (1 vCPU / 1 GB), medium (2 vCPU / 4 GB) or large (4 vCPU / 8 GB). Default small. Tier-capped (free: small; payg and enterprise: small, medium, large).
networkNetworkModeoff (default, no egress), restricted (curated allowlist), open (full egress, requires verified payment).
timeout_secondsintSandbox auto-terminates after this many seconds. Default 600. Ceiling depends on your sandbox tier (30 min free, up to 24 h with a card on file); see get_sandbox_limits. Extend a running sandbox with sbx.set_timeout().
on_timeoutOnTimeoutWhat happens at the deadline: "kill" (default) terminates the sandbox; "pause" snapshots it so a later Sandbox.resume() continues where it left off. Paid tiers always pause; the free tier falls back to terminate at the snapshot cap.
auto_resumeboolWhen True, any exec or file call on a paused sandbox resumes it automatically first, then runs - instead of erroring - and an HTTP request to one of its public preview URLs wakes it the same way, so a parked dev server comes back on the first browser hit. Pairs with on_timeout="pause": park at the deadline, wake on next use. The implicit resume passes the same admission gates as an explicit one. Default False.
preview_authboolWhen True, this sandbox's preview URLs require an access token (the response carries preview_auth_token); sbx.preview_url(port) builds the token-carrying URL, or send the token as the X-Orkestr-Preview-Token header. Default False keeps previews public.
envdict[str, str]Environment variables exposed to processes in the sandbox. In-memory only, never persisted past terminate.
metadatadict[str, str]Caller-defined tags echoed back on every response. Max 16 keys, 256 chars per value.
regionstr | NoneRegion preference, e.g. fsn1. The current list is regions from Sandbox.limits(). Omit to let the control plane pick.
allow_domainslist[str] | NoneFor network="restricted" only (paid plans): a custom egress allowlist that replaces the default set for this sandbox - still HTTPS-only and proxy-mediated. Pass bare hostnames (subdomains matched automatically) and keep any package registries you still need. Sandbox.limits() returns the default set to start from. Ignored for off/open.
volumestr | NoneAttach a persistent volume by name (or vol_ id), mounted at /persist. Data written there survives terminate and returns when a later sandbox attaches the same volume - unlike /workspace (RAM, lost on terminate). Created on first use (default 10 GB). See Persistent volumes.
Custom templates
Pass a template id (tmpl_...) to boot from an image with your dependencies baked in - same call, no install at boot. The SDK consumes templates; you build them from the console, REST API, or MCP. See the Custom templates guide.

Sandbox.get and Sandbox.list

python
# Reattach from another process
sbx = Sandbox.get("sbx_01HXYZ...")

# List your sandboxes (newest first)
for sbx in Sandbox.list(status="running"):
    print(sbx.id, sbx.template, sbx.created_at)

# Find the sandbox for a session by its metadata tags (AND across keys).
# list() auto-pages; pass max_results to cap.
for sbx in Sandbox.list(metadata={"user_id": "42", "session": "abc"}):
    print(sbx.id)

Sandbox.get reattaches to an existing sandbox by id - useful across worker processes. Sandbox.list returns the caller's sandboxes; filter by status server-side, bounded by limit (default 50, max 200).

Sandbox.limits

python
limits = Sandbox.limits()
limits.plan                        # "free"
limits.allowed_sizes               # ["small"]
"medium" in limits.allowed_sizes   # False
limits.max_concurrent              # 2    (RAM budget as a count of small boxes)
limits.max_snapshots               # 1    (paused sandboxes you may hold)
limits.trial_credit_eur            # 10.0 (one-time trial credit, EUR)
limits.trial_credit_used_eur       # 2.5  (spent so far; the trial never resets)
limits.default_egress_domains      # the restricted-mode default allowlist
for r in limits.regions:           # regions a create may pin right now
    print(r.id, r.label)
for s in limits.sizes:             # full menu, each with .allowed
    print(s.size, s.cpu, s.memory_mb, s.allowed)

Sandbox.limits reports the sizes and caps available to your API key's plan - pick a size up front instead of learning the limit from a rejected create. Useful when the same code runs under keys on different plans.

sbx.exec

Run a shell command and return the buffered result. Raises ExecTimeout if the command exceeds timeout_seconds; the sandbox itself stays alive so you can run another command.

python
result = sbx.exec("python /workspace/main.py", timeout_seconds=60)
result.stdout       # str
result.stderr       # str
result.exit_code    # int
result.duration_ms  # int
ParameterTypeDescription
command requiredstrShell command to run inside the sandbox.
cwdstrWorking directory. Default /workspace.
envdict[str, str]Per-call env overrides on top of the sandbox env.
timeout_secondsintPer-command timeout, default 60. Capped at 3600. Process gets SIGTERM, then SIGKILL after 2s.

sbx.exec_stream

Stream output as it arrives. Returns an iterator of ExecChunk objects; iteration ends with a final chunk where is_final is true. Iterate to completion - breaking early leaves the in-VM process running until its own timeout fires.

python
for chunk in sbx.exec_stream("python long_task.py"):
    if chunk.stream == "stdout":
        print(chunk.data, end="", flush=True)
    elif chunk.stream == "stderr":
        print(chunk.data, end="", flush=True, file=sys.stderr)
    if chunk.is_final:
        print(f"exit={chunk.exit_code} duration={chunk.duration_ms}ms")

Background commands

exec(..., background=True) detaches: it returns a BackgroundCommand handle immediately while the process keeps running - a dev server, a build, a long job. The process survives pause/resume and dies with the sandbox. Handles reattach from any process via sbx.commands(); a command's status is running, exited (with exit_code) or killed.

python
# Start a dev server without nohup gymnastics:
handle = sbx.exec("python -m http.server 8000", background=True)
handle.pid, handle.status        # 41, "running"

# Reattach from another process:
for cmd in sbx.commands():
    print(cmd.id, cmd.command, cmd.status)

print(handle.logs().content)     # buffered output so far
for chunk in handle.stream():    # ...or follow it live
    print(chunk.data, end="")

handle.wait(timeout=120)         # block until it stops; returns exit code
handle.kill()                    # SIGTERM the process group, then SIGKILL

sbx.run_code (code interpreter)

On the code-interpreter template, run_code executes Python in a persistent kernel: variables and imports carry over between calls, and the returned CodeExecution carries rich CodeResult items (text, HTML tables, PNG charts and parsed chart data) plus a structured CodeError when the code raises. Timeouts interrupt the kernel but preserve its variables. On other templates it raises InterpreterUnavailable. Full guide: Code interpreter.

python
sbx = Sandbox.create(template="code-interpreter")

sbx.run_code("import pandas as pd; df = pd.read_csv('sales.csv')")
execution = sbx.run_code("df.describe()")   # df persisted from the last call

execution.text                # text rendering of the final expression
execution.results[0].html     # ...and the HTML table
execution.error               # structured CodeError on a raising cell, else None

# Isolated contexts: separate interpreters inside one sandbox
ctx = sbx.create_code_context()
ctx.run_code("x = 1")         # invisible to the default context

Files namespace

sbx.files is the file operations namespace. Writes go to the file API's writable roots (/workspace, /tmp, and /persist when a volume is attached); reads and directory listings work anywhere readable inside the sandbox. Shell commands via exec are not restricted this way.

python
# Text I/O
sbx.files.write("/workspace/script.py", "print('hello')")
content = sbx.files.read("/workspace/output.txt")

# Binary I/O
sbx.files.write_bytes("/workspace/blob.bin", b"\x00\x01\x02")
raw = sbx.files.read_bytes("/workspace/blob.bin")

# Directory listing
for entry in sbx.files.list("/workspace"):
    print(entry.name, entry.is_dir, entry.size, entry.mode)

# Directory + move + stat/exists (no shelling out)
sbx.files.mkdir("/workspace/data")           # mkdir -p by default
sbx.files.move("/workspace/a", "/workspace/data/a")
st = sbx.files.stat("/workspace/data/a")     # .exists is a normal check
if st.exists and not st.is_dir:
    print(st.size, oct(st.mode))
sbx.files.exists("/workspace/nope")          # False, not an error

# Remove
sbx.files.delete("/workspace/output.txt")

Batch writes and pre-signed URLs

write_batch writes up to 64 files in one call. Files land in order, and a failed file reports in its own result row (check error) without aborting the rest. For files too big for the one-shot 16 MiB cap, presign_upload / presign_download mint expiring URLs (60 - 3600 seconds, default 600) that move bytes directly over plain HTTP - no auth header, no base64; uploads up to 512 MiB. They work for network="off" sandboxes, and a paused sandbox created with auto_resume=True wakes on first use of the URL.

python
# Many files in one call (scaffolding a project without N round-trips).
# Per-file results: a bad path fails its own row, the rest still land.
results = sbx.files.write_batch({
    "/workspace/src/app.py": "print('hi')",
    "/workspace/README.md": "# my app",
})
for r in results:
    print(r.path, r.bytes_written, r.error)   # error is None on success

# Large files: mint a pre-signed URL and move bytes directly - plain
# HTTP, no auth header, no base64, no 16 MiB cap. Reusable until expiry.
url = sbx.files.presign_upload("/workspace/dataset.csv", expires_in=600)
# requests.put(url.url, data=open("dataset.csv", "rb"))

url = sbx.files.presign_download("/workspace/out/report.pdf")
print(url.url)   # hand to a browser, curl, or another service

Persistent volumes

/workspace is fast scratch, but it is RAM-backed and gone when the sandbox terminates. To keep data across sandboxes, attach a persistent volume - a named disk mounted at /persist. Anything written there survives terminate and comes back when a later sandbox attaches the same volume. Name a volume in Sandbox.create(volume=...) and it is created on first use (default 10 GB).

python
from orkestr import Sandbox, Volume

# Attach a volume by name - created on first use (default 10 GB).
# Anything under /persist survives terminate.
sbx = Sandbox.create(template="python-3.12", volume="my-cache")
sbx.exec("echo trained-weights-v1 > /persist/model.txt")
sbx.terminate()

# A brand-new sandbox re-attaches the same volume and sees the data:
sbx = Sandbox.create(template="python-3.12", volume="my-cache")
print(sbx.exec("cat /persist/model.txt").stdout)   # trained-weights-v1

# Manage volumes explicitly (custom size, a region pin, listing, cleanup):
vol = Volume.create("datasets", size_gb=50, region="rbx")
for v in Volume.list():
    print(v.name, v.size_mb, v.status, v.region)
Volume.move(vol.id, "hel1")   # re-home a detached volume to another region
Volume.delete(vol.id)   # rejected while attached to a running sandbox

A volume backs one running sandbox at a time (attaching a busy one raises volume_in_use), and attaching one pins the sandbox to the volume's host - pass the same (or no) region.

Lifecycle webhooks

Get POSTed instead of polling: register an endpoint with Webhook.create and orkestr delivers a signed JSON payload on lifecycle events - sandbox.created / .paused / .resumed / .terminated / .expired, template.build_finished, volume.moved. Verify every delivery with verify_webhook_signature - it recomputes the HMAC over the raw body and compares in constant time. See the REST API reference for the full delivery contract (headers, retries, auto-disable).

python
from orkestr import Webhook, verify_webhook_signature

# Register once; store the secret - it is shown ONLY in this response.
whk = Webhook.create(
    "https://example.com/hooks/orkestr",
    event_types=["sandbox.paused", "sandbox.expired"],  # omit for all events
)
save_somewhere(whk.secret)   # whsec_...

# In your receiver (FastAPI shown), verify the RAW body before parsing:
@app.post("/hooks/orkestr")
async def hook(request: Request):
    raw = await request.body()
    sig = request.headers.get("X-Orkestr-Signature")
    if not verify_webhook_signature(secret, raw, sig):
        raise HTTPException(status_code=401)
    delivery = json.loads(raw)   # {"event": "sandbox.paused", "data": {...}}

Webhook.list()             # your registrations; secret never included
Webhook.enable(whk.id)     # re-arm one disabled after repeated failures
Webhook.delete(whk.id)

sbx.metrics

Live CPU and memory for the sandbox - the latest reading, a rolling ~60s sample window for sparklines, and the sandbox's lifetime totals. Use it to watch a workload for saturation or memory pressure without instrumenting the workload itself. cpu.usage_percent is the share of allocated cores; memory.usage_bytes is the working set (reclaimable cache excluded).

python
m = sbx.metrics()
m.sandbox_status        # "running" (usage is null when paused / terminated)
m.cpu.cores             # 1.0
m.cpu.usage_percent     # 94.0  (% of allocated cores; 1-core pegged = 100)
m.cpu.usage_cores       # 0.94  (cores in use)
m.memory.usage_bytes    # 188743680  (working set, excludes reclaimable cache)
m.memory.usage_percent  # 17.6  (% of m.memory.limit_bytes)
m.lifetime.cpu_seconds  # 12.84 (on-CPU seconds since the sandbox started)
for s in m.samples:     # rolling ~60s window, oldest first
    print(s.t, s.cpu_percent, s.mem_bytes)

It is telemetry, not a state change: a paused or terminated sandbox returns a result with null live usage (check sandbox_status), not an error. Pass since=datetime to fetch only samples newer than your last poll, and poll no faster than sample_interval_seconds.

sbx.get_host

Return a public hostname for an HTTP port a process in the sandbox is serving. Build the URL as f"https://{sbx.get_host(3000)}" and open it in a browser or embed it in your app for a live preview of a dev server running inside the sandbox. The URL is public - its only capability is the unguessable sandbox id in the hostname - stable for the sandbox's lifetime, but serves traffic only while the sandbox is running. WebSockets ride through, so dev-server HMR works.

python
sbx = Sandbox.create(template="node-22", network="open")

# Start a server on a port inside the sandbox (in the background):
sbx.exec("nohup python3 -m http.server 3000 >/tmp/srv.log 2>&1 &")

host = sbx.get_host(3000)     # "3000-01hxyz....sbx.orkestr.run"
url = f"https://{host}"       # open in a browser or embed it in your app

Requires a card on file and a networked sandbox (network="restricted" or "open"); it raises on an off sandbox or the free tier. See Networking & egress for the URL format and the public-link caveat.

sbx.set_timeout

Extend a running sandbox's lifetime, measured from now (a keep-alive so a busy session is not reaped mid-task). The clock is reset to run timeout_seconds from the call - not added to the remaining time - and sbx.expires_at updates in place. Capped by your tier's max_timeout_seconds; a paused or terminated sandbox has no wall-clock lifetime and raises.

python
sbx.set_timeout(3600)   # live for another hour from now
print(sbx.expires_at)   # the new deadline

sbx.pause and Sandbox.resume

pause() snapshots the sandbox and stops the compute meter. Works in every network mode. Returns the sandbox id (same as sbx.id) - persist it wherever your agent keeps state and pass to Sandbox.resume() when you want to come back. A paused sandbox is reclaimed if never resumed - sbx.paused_expires_at carries the deadline (free: 7 days, card on file: 30 days, enterprise: never) and each resume restarts the clock. Resuming consumes the snapshot and restarts the sandbox's lifetime clock. If the sandbox was created with auto_resume=True you can skip the explicit resume entirely - the next exec or file call wakes it by itself.

python
# Pause: returns the sandbox id, stops compute meter
sandbox_id = sbx.pause()

# ... later ...
sbx = Sandbox.resume(sandbox_id)

sbx.terminate (and context manager)

terminate() stops the sandbox and frees the resources. Idempotent. The recommended pattern is the context manager, which always terminates on exit:

python
with Sandbox.create(template="python-3.12") as sbx:
    result = sbx.exec("python -c 'print(2+2)'")
    print(result.stdout)
# sbx.terminate() is called automatically on exit (including on exceptions)

Result types

All result types are frozen dataclasses. No magic accessors - attributes are exactly what's documented here and what the API sends. ExecResult has stdout, stderr, exit_code, duration_ms. ExecChunk has stream, data, exit_code (only on final), duration_ms (only on final), plus is_final for convenience. FileEntry has name, is_dir, size, mode, modified_at.

Errors

Every exception the SDK raises inherits from OrkestrError. Catch the base for anything the SDK throws; catch a specific subclass to handle one failure mode.

python
from orkestr import (
    OrkestrError,
    AuthError,
    SandboxNotFound,
    ExecTimeout,
    SnapshotCapReached,
)

try:
    sbx = Sandbox.create(template="python-3.12")
    result = sbx.exec("sleep 9999", timeout_seconds=5)
except ExecTimeout:
    print("Command exceeded timeout - the sandbox is still running.")
except AuthError as e:
    print(f"Auth failed ({e.status_code}): {e.detail}")
except OrkestrError as e:
    print(f"Something else broke: {e}, request_id={e.request_id}")
ParameterTypeDescription
AuthErrorOrkestrErrorAPI key missing, invalid, expired, or scope insufficient. 401 / 403.
RateLimitErrorOrkestrErrorPlan rate limit hit. retry_after attribute carries the recommended wait.
PlanLimitErrorOrkestrErrorSandbox tier limit hit (concurrent sandboxes, trial credit exhausted).
SnapshotCapReachedPlanLimitErrorpause() blocked because the snapshot retention cap is full.
SandboxNotFoundOrkestrErrorSandbox id doesn't exist or doesn't belong to the caller. 404.
SandboxNotReadyOrkestrErrorOperation called on a sandbox in the wrong state (e.g. exec on paused). 409.
ExecTimeoutOrkestrErrorexec exceeded timeout_seconds. Sandbox stays alive.
ExecKilledOrkestrErrorExec process exited via a signal. signal attribute carries the signal number.
NetworkPolicyErrorOrkestrErrorIn-sandbox code tried to reach a host blocked by the network policy.

Advanced: OrkestrClient

The default client is built lazily from the env var on first use. For long-running agents you may want to construct one explicitly and pass it to every Sandbox.* call - this lets you change the base URL (e.g. for testing against a staging deployment), configure timeouts, or swap the underlying HTTP client. See REST API reference for raw wire format if you want to bypass the SDK entirely.

Reading with an agent? This page is also plain markdown at /docs/sandboxes/python-sdk.md, and the full docs index lives at /docs/llms.txt.