Skip to main content

orkestr Python SDK

EU-hosted sandbox VMs for AI agents. Type-safe Python client for the api.orkestr.eu/v1/sandboxes REST API.

Status: 0.1.0 - first stable release. The client surface may still have breaking changes within the 0.x line; 1.0.0 will signal API stability.

Install

pip install orkestr

Requires Python 3.10+.

Authenticate

Mint an API token in the orkestr console with the sandboxes:write scope.

export ORKESTR_API_KEY="ork_..."

The SDK picks the key up from ORKESTR_API_KEY or you pass it explicitly.

Quick start

One-shot execution inside a fresh sandbox. The with block auto-terminates the sandbox on exit, preventing runaway costs if the caller crashes.

from orkestr import Sandbox

with Sandbox.create(template="python-3.12") as sbx:
    sbx.files.write("/workspace/main.py", "print(sum(range(1_000_000)))")
    result = sbx.exec("python /workspace/main.py")
    print(result.stdout)        # 499999500000
    print(result.duration_ms)   # ~120

API

Create a sandbox

sbx = Sandbox.create(
    template="python-3.12",          # one of the templates listed below
    size="small",                    # "small" | "medium" | "large" (tier-capped)
    network="off",                   # "off" | "restricted" | "open"
    timeout_seconds=600,             # auto-terminate after this many seconds
    env={"OPENAI_API_KEY": "sk-..."},
    metadata={"agent_run": "r_123"},
    region="fsn1",                   # region code (Sandbox.limits().regions) | None for auto
    api_key=None,                    # falls back to ORKESTR_API_KEY
)
print(sbx.id)             # "sbx_01HXYZ..."
print(sbx.status)         # "running"

Custom egress allowlist (restricted sandboxes)

A restricted sandbox reaches a default set of HTTPS hosts (package registries, GitHub, major LLM APIs). Pass allow_domains to point it at your own hosts instead - an internal API, a private package mirror, a vendor endpoint. The list replaces the default entirely (still HTTPS-only and proxy-mediated), so include any registries you still need. Sandbox.limits().default_egress_domains returns the default set to start from.

sbx = Sandbox.create(
    template="python-3.12",
    network="restricted",
    allow_domains=["pypi.org", "pythonhosted.org", "api.internal.example.com"],
)

Templates

Template Description
python-3.12 CPython 3.12 with pip and common libs
python-3.12-bare CPython 3.12 only, faster start
node-22 Node 22 with npm
debian-12 Real Debian bookworm - general-purpose base where apt-get works

Sizes

size picks from a fixed menu. Allowed sizes depend on your sandbox access tier (free = email verified, payg = card on file, enterprise).

Size vCPU RAM Tiers
small 1 1 GB free, payg, enterprise
medium 2 4 GB payg, enterprise
large 4 8 GB payg, enterprise

Check your tier limits

Sandbox.limits() reports the sizes and caps available to your API key's access tier — pick a size up front instead of discovering the limit from a PlanLimitError. Handy when the same code runs under keys on different tiers (e.g. a reseller provisioning per customer).

limits = Sandbox.limits()
limits.plan                        # "free"  (your sandbox access tier)
limits.allowed_sizes               # ["small"]
"medium" in limits.allowed_sizes   # False
limits.max_concurrent              # 1
limits.trial_credit_eur            # 10.0  (one-time trial credit, EUR; payg gets 100)
limits.trial_credit_used_eur       # 12.5  (EUR of the credit consumed)
for s in limits.sizes:             # full menu, each with .allowed
    print(s.size, s.cpu, s.memory_mb, s.allowed)

Sandbox compute is a one-time EUR trial credit that does not reset: compute is metered at the public pay-as-you-go rates and drawn against the credit. When trial_credit_used_eur >= trial_credit_eur, Sandbox.create() raises PlanLimitError with code="trial_exhausted" (free hits a hard wall; paid switches to pay-as-you-go). Metering rolls up actual cgroup CPU + working-set RAM per second, so an idle sandbox accrues only what it really uses.

Live metrics

sbx.metrics() returns this sandbox's live CPU and memory - the latest reading, a rolling ~60s sample window for sparklines, and its lifetime totals. Use it to watch a workload for saturation or memory pressure without instrumenting the workload itself.

m = sbx.metrics()
m.cpu.usage_percent     # 47.0  (% of allocated cores; pegged 1-core = 100)
m.cpu.usage_cores       # 0.94  (cores in use of m.cpu.cores)
m.memory.usage_bytes    # 1879048192  (working set, excludes reclaimable cache)
m.memory.usage_percent  # 43.7  (% of m.memory.limit_bytes)
m.lifetime.cpu_seconds  # 1284.31  (on-CPU seconds since the sandbox started)
for s in m.samples:     # oldest first; s.t is a datetime
    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 m.sandbox_status) and an empty samples window - lifetime is still populated. Poll no faster than m.sample_interval_seconds; pass since=<datetime> to fetch only samples newer than your last poll.

Expose a port (preview URLs)

sbx.get_host(port) returns a public hostname for an HTTP port a process in the sandbox is serving. Build the URL as f"https://{sbx.get_host(port)}" and open it in a browser or embed it in your app: a live preview of a dev server running inside the sandbox.

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

# Start something that listens on a port (non-blocking, in the background):
sbx.exec("nohup python3 -m http.server 3000 >/tmp/srv.log 2>&1 &")
# ...or your framework's dev server, e.g. `npm run dev -- --port 3000`.

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

The URL is public - its only capability is the unguessable sandbox id in the hostname, since an embedded preview cannot send an Authorization header. It is stable for the sandbox's lifetime but only serves traffic while the sandbox is running (a paused or terminated sandbox returns nothing). WebSockets ride through, so dev-server HMR works.

get_host requires a card on file (paid tier) and a networked sandbox (network="restricted" or "open"); an off sandbox has no port to expose. It raises in either case. The sandbox GET/list payloads also carry preview_host_base (the port-less <id>.<base> suffix, or None) for building these URLs in a UI.

Run a command

result = sbx.exec("python /workspace/main.py", timeout_seconds=60)
result.stdout       # str
result.stderr       # str
result.exit_code    # int
result.duration_ms  # int

Stream a long command

for chunk in sbx.exec_stream("python long_task.py"):
    if chunk.stream == "stdout":
        print(chunk.data, end="", flush=True)
    else:
        print(chunk.data, end="", flush=True, file=sys.stderr)

Files

sbx.files.write("/workspace/data.json", '{"x": 1}')
sbx.files.write_bytes("/workspace/blob.bin", b"\x00\x01\x02")

content = sbx.files.read("/workspace/out.txt")       # returns str
raw = sbx.files.read_bytes("/workspace/blob.bin")     # returns bytes

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

sbx.files.delete("/workspace/out.txt")

# 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",
})

# 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 space, 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 under /persist survives termination and comes back when a later sandbox attaches the same volume.

# First run: write results to the volume.
sbx = Sandbox.create(template="python-3.12", volume="my-cache")
sbx.run('echo "trained-weights-v1" > /persist/model.txt')
sbx.terminate()

# Hours or days later, a brand-new sandbox re-attaches the same volume:
sbx = Sandbox.create(template="python-3.12", volume="my-cache")
print(sbx.run("cat /persist/model.txt").stdout)   # -> trained-weights-v1
print(sbx.volume)                                   # -> "my-cache"

A volume is created on first reference (default 10 GB), so you usually just name it in create(). Notes:

  • A volume can back one running sandbox at a time - attaching a volume that's already in use raises a 409 (volume_in_use); terminate the holder first.
  • A volume lives in one region, and a sandbox attaching it runs there. A new volume homes wherever its first sandbox lands (so a region on that first Sandbox.create places both); after that, passing a different region is a clear 400 - omit it, or move the volume first.
  • Manage volumes explicitly to pick a size or region up front, relocate one, or clean up:
from orkestr import Volume

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")   # detached only; data follows via its checkpoint
Volume.delete(vol.id)     # rejected while attached to a running sandbox

Valid region ids come from Sandbox.limits().regions - the live fleet, not a fixed list.

Lifecycle webhooks

Get POSTed instead of polling: register an endpoint and orkestr delivers a signed JSON payload on lifecycle events (sandbox.created / .paused / .resumed / .terminated / .expired, template.build_finished, volume.moved). The URL must be publicly reachable; the signing secret is returned only by create - store it then.

from orkestr import Webhook, verify_webhook_signature

whk = Webhook.create(
    "https://example.com/hooks/orkestr",
    event_types=["sandbox.paused", "sandbox.expired"],  # omit for all events
)
save(whk.secret)  # whsec_..., shown only here

# In your receiver, verify the RAW body before parsing:
if not verify_webhook_signature(secret, raw_body, sig_header):
    return 401  # X-Orkestr-Signature did not match

Webhook.list()           # secret never included
Webhook.enable(whk.id)   # re-arm after auto-disable (20 consecutive failures)
Webhook.delete(whk.id)

Deliveries carry X-Orkestr-Event, X-Orkestr-Delivery (stable across retries - deduplicate on it) and X-Orkestr-Signature (sha256= + HMAC-SHA256 hex of the body). Failed deliveries retry 3 times, 10 s apart.

Pause + resume

Pausing snapshots the sandbox memory + disk and stops the compute meter. Resume restores it on the same or a different host. pause() returns the sandbox id; persist it across processes and pass to Sandbox.resume.

sbx = Sandbox.create(template="node-22", network="restricted")
sandbox_id = sbx.pause()
# ... minutes or hours later, from any worker:
sbx = Sandbox.resume(sandbox_id)

Snapshot retention is tier-capped (free: 1, payg: 10, enterprise: 50). Calling pause() over the cap raises SnapshotCapReached.

Snapshot fork

Capture a running sandbox into a named snapshot, then boot any number of independent children from it - agent forking, parallel exploration of candidate fixes, or fan-out from a warmed-up environment. The source keeps running.

from orkestr import Sandbox, Snapshot

base = Sandbox.create(template="python-3.12")
base.exec("pip install -q pandas && echo ready > /workspace/state")

snap = base.snapshot(name="warmed")          # base keeps running
a = Sandbox.create(snapshot=snap.id)          # fork 1
b = Sandbox.create(snapshot=snap.id)          # fork 2 - independent of a

Snapshot.list()                               # your snapshots
Snapshot.delete(snap.id)

Each fork inherits the sandbox's filesystem and memory at snapshot time, then diverges - a write in one fork is invisible to the others and to the source. Named snapshots are tier-capped (Sandbox.limits().max_named_snapshots). v1 supports off-network, volume-less sandboxes.

Terminate

Context-manager exit calls terminate(). Outside with:

sbx.terminate()

After terminate() the sandbox row stays in your account history but the VM and any in-memory state are gone.

List your sandboxes

for sbx in Sandbox.list(status="running"):
    print(sbx.id, sbx.template, sbx.created_at)

Errors

All SDK errors inherit from orkestr.OrkestrError.

Exception When
AuthError Missing / invalid / expired API key, or scope mismatch
RateLimitError Plan rate limit hit
PlanLimitError Sandbox limit, concurrent limit, snapshot cap
FleetFull Fleet momentarily at capacity (auto-retried first)
SandboxNotFound sandbox_id doesn't exist or isn't yours
SandboxNotReady Operation called on a paused / terminated sandbox
ExecTimeout exec() exceeded timeout_seconds
NetworkPolicyError Command tried to reach a host the policy blocks
OrkestrError Any other API-level error

When the fleet is momentarily full, create() and resume() back off and retry automatically (honouring the server's Retry-After), so a transient capacity blip doesn't kill a long-running agent. They only raise FleetFull once the retry budget is spent - tune it per call with max_retries / retry_max_seconds, or pass max_retries=0 to handle the backoff yourself.

Async support

The MVP SDK is sync only. Async (AsyncSandbox) lands in v0.2.0 if a design partner blocks on it; the wire format is identical.

Versioning

Follows Semantic Versioning. The SDK targets the /v1/sandboxes API; bumps to v1 of the API are non-breaking for SDK callers. v2 of the API will require an SDK major.

Links

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

orkestr-0.5.0.tar.gz (52.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

orkestr-0.5.0-py3-none-any.whl (37.4 kB view details)

Uploaded Python 3

File details

Details for the file orkestr-0.5.0.tar.gz.

File metadata

  • Download URL: orkestr-0.5.0.tar.gz
  • Upload date:
  • Size: 52.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.13

File hashes

Hashes for orkestr-0.5.0.tar.gz
Algorithm Hash digest
SHA256 46c02ed5a3a6c084fca71d2efbcc6bd52f869eb1bf618a4f106e8668a9eac315
MD5 3839df98bd71e120591b22be4eb27642
BLAKE2b-256 879b1cbc0e0c3e550389119e3bafd13302e95bbc08af13e8a52fea9085281368

See more details on using hashes here.

File details

Details for the file orkestr-0.5.0-py3-none-any.whl.

File metadata

  • Download URL: orkestr-0.5.0-py3-none-any.whl
  • Upload date:
  • Size: 37.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.13

File hashes

Hashes for orkestr-0.5.0-py3-none-any.whl
Algorithm Hash digest
SHA256 e9683c8fe4c5534713b09a09347dba4cb6033548cb8ee89b03ebcb5f8035973a
MD5 450c7e53207398a1761429008bd8ee4f
BLAKE2b-256 bde00d083b8bfc63d4968fcdc8b3f90e4489dcd8d6d1f7fb877c15c396092440

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page