Metadata-Version: 2.5
Name: sandboxer-sdk
Version: 0.1.0
Summary: Python SDK for Sandboxer: cloud sandboxes for AI agents and humans.
Project-URL: Homepage, https://sandboxer.com
Project-URL: Documentation, https://sandboxer.com/docs/sdk-python
Author-email: Sandboxer <support@sandboxer.com>
License-Expression: MIT
License-File: LICENSE
Keywords: agents,ai,cloud,microvm,sandbox,sandboxer,sdk
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
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: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx<1,>=0.27
Requires-Dist: pydantic<3,>=2.7
Provides-Extra: dev
Requires-Dist: datamodel-code-generator==0.83.0; extra == 'dev'
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: pyyaml>=6; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff==0.17.0; extra == 'dev'
Description-Content-Type: text/markdown

# sandboxer-sdk — Python SDK

Python client for [Sandboxer](https://sandboxer.com): persistent cloud sandboxes (Ubuntu microVMs with root,
Docker, SSH, HTTPS port hosting, stop/resume, forks, snapshots, a streamed desktop and integrated coding agents).

- **High-level API** — `Sandboxer` / `Sandbox` (sync) and `AsyncSandboxer` / `AsyncSandbox` (asyncio).
- **Low-level API** — `SandboxerApi` / `AsyncSandboxerApi`: one typed method for every operation of the public
  API (`/api/v1`), wire-compatible with boat's API v1 so boat-sdk code ports by renaming imports.
- Typed pydantic models, envelope-aware errors, automatic retries with jittered backoff, automatic
  `Idempotency-Key` on create/fork, NDJSON command streaming, event streaming (polling and SSE) and webhook
  signature verification.

Requires Python 3.10+.

```bash
pip install sandboxer-sdk     # imported as `import sandboxer`
```

## Configure

| Option | Default |
| --- | --- |
| `api_key` | `SANDBOXER_API_KEY`, then `SANDBOXER_TOKEN` (the per-sandbox key injected inside sandboxes) |
| `base_url` | `SANDBOXER_API_URL`, else `https://sandboxer.com/api/v1` |
| `org` | `SANDBOXER_ORG`; sent as `X-Sandboxer-Org` (billing wallet) |
| `timeout` | `60` seconds (`None` disables it) |
| `max_retries` | `3` |
| `http_client` | your own `httpx.Client` / `httpx.AsyncClient` (not closed by the SDK) |
| `default_headers` | extra headers for every request |

```python
from sandboxer import Sandboxer

client = Sandboxer()                       # reads SANDBOXER_API_KEY
client = Sandboxer(api_key="sbx_live_...", org="team_acme", timeout=30)
```

Keep API keys, secrets, SSH keys and desktop/hosted URLs (they can carry access tokens) out of your logs.

## Quickstart

```python
from sandboxer import Sandboxer

with Sandboxer() as client:
    sandbox = client.create(type="small", ttl_seconds=1800)   # waits until it is ready

    result = sandbox.exec("python3 --version")
    print(result.exit_code, result.stdout)

    sandbox.files.write("app/main.py", "print('hi')\n")
    print(sandbox.files.read("app/main.py"))

    sandbox.start("python3 -m http.server 8000", cwd="app")      # detached
    print(sandbox.host(8000, public=True).url)                   # https://<slug>-8000.box.sandboxer.com

    sandbox.stop()                                                # archive (resume later)
```

`Sandboxer.create(...)`, `Sandboxer.get(...)` and `Sandboxer.list(...)` also work on the class itself, using a
default client configured from the environment:

```python
sandbox = Sandboxer.create(ttl_seconds=600)
```

### Create options

`client.create(type=, ttl_seconds=, env=, no_env=, from_=, setup_script=, environment=, snapshots=, monitoring=,
fail_fast=, org=, name=, idempotency_key=, wait=True, wait_for_setup=False, timeout=300, on_exit=None)`

- `ttl_seconds=None` disables auto-stop (omitting it uses the server default of one hour).
- `from_="web-stack"` starts from a named snapshot (or a `bsh_...` share code).
- `no_env=True` withholds every account secret — use it for sandboxes you hand to your own users.
- `wait_for_setup=True` also waits for `setup_script` and raises if it failed.
- Waiting raises `SandboxStateError` if the sandbox ends in `error`/`cancelled`, `WaitTimeoutError` on timeout.

## Commands

```python
r = sandbox.exec(["ls", "-la", "my dir"], cwd="project", env={"DEBUG": "1"}, timeout_seconds=120)
r.stdout, r.stderr, r.exit_code, r.success, r.timed_out

sandbox.exec("make test", check=True)        # raises CommandError on a non-zero exit
```

Commands run through the shell; a list is quoted for you. The API has no `env` field for commands, so `env` is
exported in front of the command line.

### Streaming output

```python
stream = sandbox.exec_stream("npm install")
for frame in stream:                          # started, stdout/stderr chunks, then exit or error
    if frame.type in ("stdout", "stderr"):
        print(frame.data, end="")
result = stream.wait()                        # CommandResult with the collected output
```

### Detached commands

```python
server = sandbox.start("node server.js", cwd="app")
server.process_id, server.pid, server.command_id

server.status()                               # running?, exit code, log tails
for frame in server.attach():                 # live output; reconnects transparently
    print(frame.type, frame.data)
server.kill()                                 # SIGTERM, then SIGKILL after 5 s
server.wait()                                 # follow to the end -> CommandResult
server.logs()                                 # full stored stdout from the command history

sandbox.command_status(42); sandbox.attach(42); sandbox.kill(42)   # by process id
```

## Files

```python
sandbox.files.write("notes.txt", "hello\n")          # text
sandbox.files.write("logo.png", png_bytes)           # bytes (sent base64)
sandbox.files.read("notes.txt")                      # -> str   (up to 5 MiB)
sandbox.files.read_bytes("logo.png")                 # -> bytes (up to 5 MiB)
sandbox.files.download("build/app.tar.gz")           # -> bytes (up to 50 MiB)
```

Paths are absolute or relative to `/home/user`, and must resolve under `/home/user` or `/tmp`.

## Hosting, SSH and desktop

```python
sandbox.host(3000)                          # private URL with a _token query parameter
sandbox.host(3000, public=True)             # or private=False
sandbox.host(5173, localhost=True)          # service bound to 127.0.0.1 only
sandbox.ssh_key(Path.home().joinpath(".ssh/id_ed25519.pub").read_text())  # then ssh user@<machine_ip>
sandbox.desktop(public_access=True).desktop_url         # waits while VNC is provisioning
```

## Lifecycle

```python
sandbox.stop()                        # waits until archived (wait=False to return at once)
sandbox.resume(type="large")          # waits until ready again
child = sandbox.fork()                # new Sandbox from the current state; the source is untouched
sandbox.snapshot("web-stack")         # named snapshot, waits until ready -> deploy with create(from_="web-stack")
sandbox.extend(7200)                  # auto-stop in 2 h from now (None disables auto-stop)
sandbox.update(name="demo", subdomain="acme-demo")
sandbox.usage(); sandbox.processes(); sandbox.history(); sandbox.snapshots(); sandbox.latest_snapshot()
sandbox.delete()                      # PERMANENT: deletes the sandbox and its snapshots

for sb in client.list(state=["ready", "idle"]):
    print(sb.id, sb.state)
```

### Context managers

```python
with client.create(on_exit="delete") as sandbox:   # or on_exit="stop"; default None leaves it running
    sandbox.exec("pytest", check=True)
```

`on_exit` can also be set later: `sandbox.on_exit = "stop"`.

## Agents: prompts and events

```python
run = sandbox.prompt("Run the tests and fix what fails.", provider="codex", model="gpt-5.4")
for event in run.events():              # this run's events, ending when it is done
    if event.type == "response" and event.data.get("content"):
        print(event.data["content"])
run.wait()                              # PromptRun: finished / failed / interrupted
print(run.text())                       # final response text
run.steer("Skip the integration tests.")
run.interrupt()

for event in sandbox.events(type="prompt,response"):   # every conversation, follows new events
    ...
sandbox.conversations(); sandbox.steer("..."); sandbox.interrupt(conversation="...")
```

`sandbox.events(mode=...)`: `"poll"` long-polls `GET /events?sort=asc` with the page cursor, `"sse"` uses
`text/event-stream` (reconnecting with `Last-Event-ID`), `"auto"` (default) tries SSE and falls back to polling.
Repeated pages are de-duplicated, while an event whose payload changed (a streaming partial becoming final) is
yielded again.

## Async

Everything above exists with `async`/`await` on `AsyncSandboxer` / `AsyncSandbox`:

```python
import asyncio
from sandboxer import AsyncSandboxer

async def main() -> None:
    async with AsyncSandboxer() as client:
        async with await client.create(on_exit="delete") as sandbox:
            print((await sandbox.exec("uname -a")).stdout)
            async for frame in sandbox.exec_stream("ls -R /etc"):
                ...

asyncio.run(main())
```

Async versions of the helper functions are in `sandboxer.aio`.

## Low-level client

`client.api` is a `SandboxerApi` with one method per API operation (snake_case operation ids):

```python
from sandboxer import models

api = client.api
api.me(); api.limits(); api.sandboxes(state="ready,idle")
created = api.create(ttl_seconds=None, no_env=True)                 # keyword body fields...
api.update(created.sandbox.id, models.UpdateSandboxRequest(name="x"))  # ...or a body model / dict
api.events(sandbox_id, sort="asc", limit=50, type="prompt,response")
for frame in api.command_stream(sandbox_id, command="make"): ...
api.request("GET", "/provider-models")                             # escape hatch
```

- Keyword arguments left out are not sent; `None` is sent as JSON `null` (`ttl_seconds=None` disables auto-stop).
- `request_options={"timeout": 5, "max_retries": 0, "headers": {...}, "org": "team_x", "idempotency_key": "..."}`
  works on every method.
- Deletes fill `X-Sandboxer-Confirm-Delete` with the target id for you (override with `confirm_delete=`).
- Responses are pydantic models with snake_case attributes (`sandbox.memory_gb`); unknown fields are kept
  (`model_extra`) and unexpected shapes are logged instead of raising.

## Errors, retries and idempotency

Every failure raises `SandboxerError` (subclasses: `BadRequestError`, `AuthenticationError`,
`PaymentRequiredError`, `PermissionDeniedError`, `NotFoundError`, `ConflictError`, `RateLimitError`,
`InternalServerError`, `APIConnectionError`, `APITimeoutError`, plus `WaitTimeoutError`, `SandboxStateError`,
`CommandError`, `WebhookVerificationError`).

```python
from sandboxer import SandboxerError

try:
    sandbox.exec("true")
except SandboxerError as e:
    print(e.status, e.code, e.message, e.details, e.request_id, e.retryable)
```

Automatic retries (default 3, jittered exponential backoff, `Retry-After` honored):

| Request | Retried on |
| --- | --- |
| `GET` | network errors, timeouts, 5xx, 429 with `Retry-After`, `sandbox_starting`, retryable `sandbox_direct_failed` |
| `POST /commands` | only `sandbox_starting`, `sandbox_direct_failed` + `sandbox_restoring` (the command provably did not run) and 429 with `Retry-After` — never network errors or 5xx, since the command may already be running |
| create / fork | network errors, timeouts, 5xx, 429 with `Retry-After`, `409 idempotency_in_progress` — safe because each call carries an `Idempotency-Key` (a UUID, reused across retries; pass `idempotency_key=` to choose it) |
| other writes | 429 with `Retry-After`, `sandbox_starting`, retryable `sandbox_direct_failed` |

## Webhooks

Deliveries carry `Sandboxer-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>`. Verify the raw
bytes before parsing:

```python
from sandboxer import construct_event, verify_webhook, WebhookVerificationError

# FastAPI
@app.post("/hooks/sandbox")
async def hook(request: Request):
    raw = await request.body()
    try:
        event = construct_event(raw, request.headers, os.environ["SANDBOXER_WEBHOOK_SECRET"])
    except WebhookVerificationError:
        raise HTTPException(400)
    if event.type == "sandbox.ready":
        ...
    return {"ok": True}

# Flask
@app.post("/hooks/sandbox")
def hook():
    if not verify_webhook(request.get_data(), request.headers.get("Sandboxer-Signature"), SECRET):
        abort(400)
    ...
```

- `tolerance_seconds` (default 300) rejects stale timestamps; pass a list of secrets while rotating.
- `verify_webhook_headers(raw, headers, secret)` / `construct_event(raw, headers, secret)` also accept the
  `X-Sandboxer-Signature` / `X-Sandboxer-Delivery` / `X-Sandboxer-Timestamp` header set.
- Deliveries are at-least-once and may arrive out of order: de-duplicate on `event.id`.

## Porting from boat-sdk

| boat-sdk | sandboxer |
| --- | --- |
| `from boat_sdk.api.boat_api import BoatApi` | `from sandboxer import SandboxerApi` (or `Sandboxer().api`) |
| `Configuration(host=..., access_token=...)` | `Sandboxer(base_url=..., api_key=...)` |
| `BOAT_API_KEY`, `X-Boat-Org` | `SANDBOXER_API_KEY`, `X-Sandboxer-Org` (`org=`) |
| `boat_sdk.models.CreateSandboxRequest(var_from=...)` | `sandboxer.models.CreateSandboxRequest(from_=...)` |
| `sandbox.create(CreateSandboxRequest(ttl_seconds=1800))` | `api.create(CreateSandboxRequest(ttl_seconds=1800))` or `api.create(ttl_seconds=1800)` |
| `sandbox.get(id)`, `update`, `stop`, `resume`, `fork`, `delete_sandbox`, `prompt`, `events`, `read_file`, `write_file`, `command`, `artifact`, `desktop`, `ssh_key`, snapshot methods ... | same names on `SandboxerApi` |
| `ApiException` (`.status`, `.body`) | `SandboxerError` (`.status`, `.code`, `.message`, `.details`, `.request_id`) |
| `wait_until_ready`, `wait_until_idle`, `wait_for_desktop`, `wait_for_prompt`, `wait_for_prompt_done`, `stream_events`, `stream_prompt`, `stop_and_remove`, `read_text`, `write_text`, `exec_command` | same names in `sandboxer` (async versions in `sandboxer.aio`) |

## Development

```bash
python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
.venv/bin/python scripts/generate.py     # regenerate models + low-level client from ../../api/openapi.yaml
.venv/bin/python scripts/unasync.py      # regenerate the sync modules from the async sources
.venv/bin/pytest -q && .venv/bin/ruff check src tests && .venv/bin/mypy --strict src
```

`models.py`, `_generated_api.py` and the `*_sync.py` modules are generated: edit the generator or the async sources.

## Support

Questions and bug reports: [support@sandboxer.com](mailto:support@sandboxer.com). Full docs: [sandboxer.com/docs/sdk-python](https://sandboxer.com/docs/sdk-python).

## License

MIT
