# 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](https://orkestr.eu/sandbox) and mint a scoped token. ## Install ```bash 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 ```python 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) ``` | Parameter | Type | Description | | --- | --- | --- | | template required | Template | A 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. | | size | SandboxSize | Fixed 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). | | network | NetworkMode | `off` (default, no egress), `restricted` (curated allowlist), `open` (full egress, requires verified payment). | | timeout_seconds | int | Sandbox 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_timeout | OnTimeout | What 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_resume | bool | When `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_auth | bool | When `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. | | env | dict[str, str] | Environment variables exposed to processes in the sandbox. In-memory only, never persisted past terminate. | | metadata | dict[str, str] | Caller-defined tags echoed back on every response. Max 16 keys, 256 chars per value. | | region | str \| None | Region preference, e.g. `fsn1`. The current list is `regions` from `Sandbox.limits()`. Omit to let the control plane pick. | | allow_domains | list[str] \| None | For `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`. | | volume | str \| None | Attach 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](https://orkestr.eu/docs/sandboxes/python-sdk#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](https://orkestr.eu/docs/sandboxes/templates). ### 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 ``` | Parameter | Type | Description | | --- | --- | --- | | command required | str | Shell command to run inside the sandbox. | | cwd | str | Working directory. Default `/workspace`. | | env | dict[str, str] | Per-call env overrides on top of the sandbox env. | | timeout_seconds | int | Per-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](https://orkestr.eu/docs/sandboxes/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](https://orkestr.eu/docs/sandboxes/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](https://orkestr.eu/docs/sandboxes/networking) 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}") ``` | Parameter | Type | Description | | --- | --- | --- | | AuthError | OrkestrError | API key missing, invalid, expired, or scope insufficient. 401 / 403. | | RateLimitError | OrkestrError | Plan rate limit hit. `retry_after` attribute carries the recommended wait. | | PlanLimitError | OrkestrError | Sandbox tier limit hit (concurrent sandboxes, trial credit exhausted). | | SnapshotCapReached | PlanLimitError | pause() blocked because the snapshot retention cap is full. | | SandboxNotFound | OrkestrError | Sandbox id doesn't exist or doesn't belong to the caller. 404. | | SandboxNotReady | OrkestrError | Operation called on a sandbox in the wrong state (e.g. exec on paused). 409. | | ExecTimeout | OrkestrError | exec exceeded timeout_seconds. Sandbox stays alive. | | ExecKilled | OrkestrError | Exec process exited via a signal. `signal` attribute carries the signal number. | | NetworkPolicyError | OrkestrError | In-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](https://orkestr.eu/docs/sandboxes/api-reference) for raw wire format if you want to bypass the SDK entirely.