Metadata-Version: 2.4
Name: orkestr
Version: 0.4.1
Summary: EU-hosted sandbox VMs for AI agents - official Python SDK
Author-email: Orkestr <hello@orkestr.eu>
License-Expression: MIT
Project-URL: Homepage, https://orkestr.eu/sandboxes
Project-URL: Documentation, https://orkestr.eu/docs
Keywords: orkestr,sandbox,ai,agents,code-execution,eu
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.27
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: respx>=0.21; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Dynamic: license-file

# 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

```bash
pip install orkestr
```

Requires Python 3.10+.

## Authenticate

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

```bash
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.

```python
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

```python
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.

```python
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).

```python
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.

```python
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.

```python
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

```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
```

### Stream a long command

```python
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

```python
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")
```

### 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.

```python
# 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:

```python
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.

```python
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`.

```python
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`.

### Terminate

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

```python
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

```python
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](https://semver.org). 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

- [Sandboxes](https://orkestr.eu/sandboxes)
- [Public API docs](https://orkestr.eu/docs)
- Questions and bug reports: <code@orkestr.eu>
