> ## Documentation Index
> Fetch the complete documentation index at: https://docs.boxd.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Python SDK

> Programmatic access to boxd machines from Python, sync and async.

The `boxd` package gives you programmatic access to the full boxd API — create and manage machines, run commands in them, stream output, move files, and manage proxies, snapshots, disks, env vars and secrets. It talks to the same API as the [CLI](/reference/external-cli), so everything you can do from the terminal you can do from Python.

Requires Python 3.10+.

## Install

<CodeGroup>
  ```bash pip theme={"theme":"github-dark"}
  pip install boxd
  ```

  ```bash uv theme={"theme":"github-dark"}
  uv add boxd
  ```
</CodeGroup>

## Quick start

```python theme={"theme":"github-dark"}
from boxd import Boxd

boxd = Boxd(api_key="bxd_...")

machine = boxd.machines.create("my-machine")
boxd.machines.wait_until_ready(machine.id)

result = boxd.machines.exec(machine.id, "uname -a")
print(result.stdout)

boxd.machines.delete(machine.id)
boxd.close()
```

The client is the only stateful object. Everything else is a namespace of flat methods that return plain data — a `Machine` is a record of fields, not a handle, and every operation takes the machine id (or name) as its first argument: **`boxd.<resource>.<verb>(id, ...)`**.

There are eight namespaces:

| Namespace        | For                                                           |
| ---------------- | ------------------------------------------------------------- |
| `boxd.machines`  | machines, plus `.files`, `.ports`, `.proxies`, `.checkpoints` |
| `boxd.snapshots` | reusable point-in-time captures                               |
| `boxd.disks`     | persistent disks                                              |
| `boxd.env`       | environment variables                                         |
| `boxd.secrets`   | secrets (write-only values)                                   |
| `boxd.orgs`      | the organizations you belong to                               |
| `boxd.api_keys`  | API keys                                                      |
| `boxd.account`   | your identity and cluster defaults                            |

## The client

```python theme={"theme":"github-dark"}
Boxd()                                            # production
Boxd(api_key="bxd_...")
Boxd(base_url="https://boxd.example.com:9443")    # any other cluster
```

<Note color="#E05A6D">
  `base_url` selects the cluster.
</Note>

| Argument      | Environment variable                               | Default               |
| ------------- | -------------------------------------------------- | --------------------- |
| `api_key`     | `BOXD_API_KEY`                                     | —                     |
| `token`       | `BOXD_TOKEN`                                       | —                     |
| `base_url`    | `BOXD_BASE_URL` (or the deprecated `BOXD_API_URL`) | `http://boxd.sh:9443` |
| `timeout`     | —                                                  | 60 seconds            |
| `max_retries` | —                                                  | 2                     |

All arguments are keyword-only. `base_url` accepts an optional scheme that controls TLS:

| Value               | Transport                         |
| ------------------- | --------------------------------- |
| `http://host:port`  | plaintext                         |
| `https://host:port` | TLS                               |
| bare `host:port`    | TLS, except `localhost` / `127.*` |

Failed connections are retried up to `max_retries` times with exponential backoff. Timeouts are never retried — the request may already have been applied — and authentication failures are never retried either.

The client holds a connection, so keep one around rather than making a new one per call. Close it when you're done, or use it as a context manager:

```python theme={"theme":"github-dark"}
with Boxd(api_key="bxd_...") as boxd:
    ...
```

## Sync and async

`Boxd` and `AsyncBoxd` are two top-level classes with the same surface — same namespaces, same method names, same arguments, same return types. Switching is `await` and an import, not a rewrite.

```python theme={"theme":"github-dark"}
from boxd import AsyncBoxd

async with AsyncBoxd(api_key="bxd_...") as boxd:
    machine = await boxd.machines.create("my-machine")
    result = await boxd.machines.exec(machine.id, "echo hello")
```

Use `AsyncBoxd` when you already have an event loop (FastAPI, asyncio scripts, anyio). Use `Boxd` everywhere else — scripts, notebooks, Django views.

|                 | `Boxd`                      | `AsyncBoxd`                                |
| --------------- | --------------------------- | ------------------------------------------ |
| Call style      | `boxd.machines.create(...)` | `await boxd.machines.create(...)`          |
| Context manager | `with`                      | `async with`                               |
| Best for        | scripts, CLIs, notebooks    | FastAPI, async workers, concurrent fan-out |

## Authentication

```python theme={"theme":"github-dark"}
Boxd(api_key="bxd_...")   # recommended
Boxd(token="...")         # a token you already hold
Boxd()                    # BOXD_API_KEY, then BOXD_TOKEN
```

Credentials are resolved in this order, first match wins:

1. an explicit `token`
2. an explicit `api_key`
3. `BOXD_TOKEN`
4. `BOXD_API_KEY`
5. automatic, inside a boxd machine (below)

If none of those produce a credential, the first request raises `AuthenticationError`.

Mint a key with `boxd auth keys create NAME` (see the [CLI](/reference/external-cli#api-keys)), in the [console](https://boxd.sh/app), or with [`boxd.api_keys.create()`](#api-keys). An API key is exchanged for a short-lived session and kept fresh for you. Revoking a key takes effect immediately, so if a key is revoked mid-run the next call raises `AuthenticationError` rather than retrying.

### Inside a machine

Inside a boxd machine, `Boxd()` authenticates automatically — no API key, no configuration:

```python theme={"theme":"github-dark"}
boxd = Boxd()
for machine in boxd.machines.list():
    print(machine.name, machine.status)
```

It also targets the cluster the surrounding machine belongs to, so the same code runs unchanged wherever it is deployed. An explicit `base_url` or credential still wins.

<Warning>
  Inside a **shared** machine the SDK can manage the organization's shared machines, but it cannot read env vars or secrets, and cannot reach machines that are private to another member. Pass an API key for those.
</Warning>

## Machines

```python theme={"theme":"github-dark"}
machine = boxd.machines.create("my-machine")
one     = boxd.machines.get("my-machine")   # id or name
all     = boxd.machines.list()              # list[Machine]
boxd.machines.delete("my-machine")
```

`list()` returns a plain list. Pass `org="acme"` for one organization's machines, or `all_contexts=True` for everything you can reach.

State:

```python theme={"theme":"github-dark"}
boxd.machines.start(id)
boxd.machines.stop(id)
boxd.machines.reboot(id)
boxd.machines.pause(id)        # suspend to RAM — PauseResult(suspend_us=...)
boxd.machines.resume(id)       # ResumeResult(resume_us=...)
boxd.machines.hibernate(id)    # suspend to disk
boxd.machines.wake(id)
```

See [Suspend & resume](/how-it-works/suspend-resume) for the difference between `stop`/`start` (cold) and `pause`/`resume` (warm).

Everything else:

```python theme={"theme":"github-dark"}
fork = boxd.machines.fork("my-machine", "my-copy")   # live clone
boxd.machines.share(id)                              # visible to your whole org
boxd.machines.unshare(id)
boxd.machines.rename(id, "new-name")                 # returns the new name
boxd.machines.set_auto_suspend_timeout(id, 300)      # seconds; 0 disables
boxd.machines.set_auto_hibernate_timeout(id, 3600)
boxd.machines.wait_until_ready(id)                   # running *and* exec works
boxd.machines.suggest_name()                         # a free, generated name
```

<Warning>
  `rename` reboots the machine. It is its own call rather than part of an `update()` for exactly that reason.
</Warning>

`create` and `fork` return once the machine is scheduled, not once it is usable. Call `wait_until_ready` before doing anything that depends on it running — especially before [forking](/how-it-works/fork) it again. It polls for up to 90 seconds by default (`timeout=`, `poll_interval=`, both in seconds).

### Creating

```python theme={"theme":"github-dark"}
boxd.machines.create(
    "builder",
    image="ubuntu:24.04",
    env={"API_URL": "https://example.com"},
    cmd=["/usr/local/bin/start"],
    restart_policy="always",          # "always" | "never"
    vcpu=2,
    memory="8G",
    disk="100G",
    auto_suspend_timeout=300,         # seconds; 0 disables
    auto_destroy_timeout=0,
    ssh=True,
)

# In an organization. `shared` makes it visible to every member.
boxd.machines.create(org="acme", shared=True)

# From a snapshot instead of an image.
boxd.machines.create("from-golden", from_snapshot="golden")
```

Sizing and lifecycle are flat keyword arguments — there is no config object. Every one is optional; `create()` boots the [default image at the default size](/reference/resources). `fork(source, name)` takes the same keywords, and anything you leave out is inherited from the source.

Proxies and disk mounts are set up at create time with two small models:

```python theme={"theme":"github-dark"}
from boxd import ProxyEntry, VolumeMount

boxd.machines.create(
    "api-server",
    proxies=[ProxyEntry(name="api", port=3000)],
    volumes=[VolumeMount(disk_id="d_...", mount_path="/data")],
)
```

<Note color="#E05A6D">
  A snapshot restores the machine that was captured, so `create(from_snapshot=...)` takes only `name`, `org` and the sizing keywords. Passing `image`, `env`, `cmd`, `restart_policy` or `shared` alongside it raises `ValueError` rather than silently ignoring them.
</Note>

Renaming reboots the machine, so it is its own call. Everything else is readable straight off `Machine`.

### The `Machine` record

```python theme={"theme":"github-dark"}
machine.id
machine.name
machine.status            # "pending" | "starting" | "running" | "suspended" |
                          # "hibernated" | "stopped" | "failed" | "destroyed" | "migrating"
machine.image_ref         # the image the machine boots
machine.restart_policy    # str | None
machine.created_at        # datetime | None

machine.resources.vcpu           # the machine's CPU, memory and disk
machine.resources.memory_bytes
machine.resources.disk_bytes

machine.org               # OrgRef(id, name) | None — None = your personal quota
machine.shared            # shared with that org, or private to you

machine.access.ssh_port   # the port to SSH to; None if it has none yet
machine.access.domain     # the domain the machine is addressed under
machine.access.url        # the machine's public HTTPS address

machine.idle.suspend_after     # seconds of inactivity before each action;
machine.idle.hibernate_after   # 0 disables it
machine.idle.destroy_after

machine.source            # MachineSource | None — None = booted from an image
machine.source.kind       # "fork" | "snapshot"
machine.source.name       # the source machine, or the snapshot name
machine.source.version    # the snapshot version; a fork has none
machine.source.id         # the machine or snapshot this came from

machine.hibernated_at     # when it went to disk; None = not hibernated
machine.last_connected_at # datetime | None — None = never connected
machine.boot_time_ms      # how long the last boot took
```

`org` is the organization the machine belongs to and is billed to; `shared` says whether your teammates can see it. A private machine can still be org-billed, so `org` set with `shared=False` is normal, not a contradiction — see [Organizations](/organizations/overview).

`source.id` **may not resolve** if the machine or snapshot it points at was since deleted. A lookup that finds nothing is normal.

Models are pydantic, so `machine.model_dump()` gives you a plain dict. An unrecognised `status` is passed through rather than rejected, so a machine in a newly added state still prints.

### Exec

One-shot exec collects the output:

```python theme={"theme":"github-dark"}
result = boxd.machines.exec(id, "cargo build")
result.stdout      # str
result.stderr      # str
result.exit_code   # int
result.success     # bool — exit_code == 0

boxd.machines.exec(id, ["echo", "a b"])              # a list is quoted for you
boxd.machines.exec(id, "env", env={"FOO": "bar"})
boxd.machines.exec(id, "pytest", timeout=30)         # seconds
```

A list `command` is shell-quoted for you; a string is passed through as written. `timeout` is in seconds and gives up on the call — the remote process may keep running. `exec` also takes `tty`, `cols` and `rows`: under a PTY the terminal merges stderr into stdout, so `stderr` comes back empty and everything lands in `stdout`.

For anything interactive, `stream_exec` gives you a live session — the one handle in the SDK, because a bidirectional stream really is stateful:

```python theme={"theme":"github-dark"}
with boxd.machines.stream_exec(id, command="bash", tty=True) as stream:
    stream.write(b"ls\n")
    stream.write_eof()
    for chunk in stream:
        print(chunk.decode(errors="replace"), end="")
    print("exited", stream.exit_code)
```

Iterating the stream yields merged output — what a terminal would show. `iter_chunks()` yields `OutputChunk(data, is_stderr)` when you need the two streams apart. Under `tty=True` the terminal merges them, so everything arrives as stdout; set `tty=False` if you need the split. `exit_code` is `None` until the stream is exhausted.

Set the terminal size with `cols`/`rows`, and call `stream.resize(cols, rows)` when the local terminal changes size:

```python theme={"theme":"github-dark"}
import shutil, signal

cols, rows = shutil.get_terminal_size()
stream = boxd.machines.stream_exec(id, command="htop", tty=True, cols=cols, rows=rows)
signal.signal(signal.SIGWINCH, lambda *_: stream.resize(*shutil.get_terminal_size()))
```

Unset `cols`/`rows` fall back to 80×24. `resize()` on a non-PTY exec is a harmless no-op.

Headless one-shots that read stdin (`jq`, `cat`, `claude -p`) hang waiting for input. Pass `close_stdin=True` to send end-of-input immediately — or call `write_eof()` yourself. Combining it with `tty=True` raises `ValueError`, since a shell needs stdin open.

### Logs

```python theme={"theme":"github-dark"}
for chunk in boxd.machines.logs(id):
    print(chunk.decode(errors="replace"), end="")

for chunk in boxd.machines.logs(id, follow=True):   # stays open
    ...
```

`follow=True` keeps the stream open for new output.

## Files

```python theme={"theme":"github-dark"}
boxd.machines.files.upload(id, "/app/config.json", '{"debug": true}')
boxd.machines.files.upload(id, "/app/data.bin", open("local.bin", "rb").read())
data = boxd.machines.files.download(id, "/app/output.json")   # bytes
```

`upload` takes `str` or `bytes` and returns the number of bytes written; large uploads are chunked for you. Paths inside the machine are absolute, or relative to `/home/boxd`.

## Ports

Raw TCP/UDP forwards on a public address — see [Port forwarding](/how-it-works/port-forwarding). Max 3 per machine.

```python theme={"theme":"github-dark"}
fwd = boxd.machines.ports.expose(id, 5432, protocol="tcp")
fwd.dns             # hostname to connect to
fwd.public_port     # port to connect on
fwd.machine_port    # the port inside the machine
fwd.protocol        # "tcp" | "udp" | "both"

boxd.machines.ports.list(id)    # one machine's forwards
boxd.machines.ports.list()      # every forward you own
boxd.machines.ports.unexpose(id, 5432)
```

Re-exposing the same machine port keeps its public port and just updates the protocol; `"both"` shares one public port across TCP and UDP.

<Note color="#E05A6D">
  On `AsyncBoxd`, `ports.list()` takes no machine argument — it returns every forward you own. Filter on `.machine_id` or `.machine_name`.
</Note>

## Proxies

HTTPS routes into a machine — see [Proxies](/how-it-works/proxies). The machine argument takes an id or a name, like everywhere else.

```python theme={"theme":"github-dark"}
boxd.machines.proxies.create("my-machine", "api", 3000)
routes = boxd.machines.proxies.list("my-machine")
routes[0].port          # int — where traffic actually goes
routes[0].port_mode     # "locked" (you pinned it) | "auto" (detected for you)
routes[0].domain
routes[0].is_default

boxd.machines.proxies.set_port("my-machine", 3001, name="api")
boxd.machines.proxies.set_port("my-machine", "auto")   # default route, auto-detected
boxd.machines.proxies.delete("my-machine", "api")
```

`name` and `port` are both required on `create` — a named route is always pinned to a port. `"auto"` is only accepted for a machine's default route, which is what `set_port` addresses when you leave `name` off.

## Checkpoints

Per-machine save points, restored in place — see [Checkpoints](/how-it-works/checkpoints). They are deleted with the machine.

```python theme={"theme":"github-dark"}
boxd.machines.checkpoints.create(id, "before-upgrade")   # machine must be running
points = boxd.machines.checkpoints.list(id)
points[0].status        # "pending" | "ready" | "failed"
points[0].available     # restorable right now
points[0].size_bytes
points[0].created_at

boxd.machines.checkpoints.restore(id, "before-upgrade")
boxd.machines.checkpoints.delete(id, "before-upgrade")
```

`create` returns while the capture is still `"pending"`.

## Snapshots

Reusable, named captures — see [Snapshots](/how-it-works/snapshots). Boot one with `machines.create(from_snapshot=...)`.

```python theme={"theme":"github-dark"}
boxd.snapshots.create(machine_id, "golden")
snap = boxd.snapshots.get("golden")
boxd.snapshots.list()
boxd.snapshots.delete("golden")
```

Saving under an existing name adds a version. A `Snapshot` carries `created_at` (the first capture), `updated_at` (the most recent one), `version`, `status`, `size_bytes`, `vcpu`, `memory_bytes` and `use_count`. `get`, `list` and `delete` take an optional `org=`.

## Disks

```python theme={"theme":"github-dark"}
disk = boxd.disks.create("data", "10G")
boxd.disks.attach(disk.id, machine_id, "/mnt/data")
boxd.disks.attach(disk.id, machine_id, "/mnt/data", read_only=True)
boxd.disks.detach(disk.id, machine_id)
boxd.disks.list()
boxd.disks.delete(disk.id)
```

`size` takes a human string (`"10G"`) or a byte count. A disk is always created writable; read-only is chosen per attachment. A disk can be attached to one machine at a time. `status` is `"creating"`, `"ready"` or `"destroyed"` — it can only be attached once it is `"ready"`. `list()` also returns each disk's current `attachments`.

You can mount a disk at create time instead, with `volumes=[VolumeMount(...)]`.

## Env vars and secrets

Two namespaces with identical methods — see [Env vars & secrets](/reference/env-secrets). The difference is what comes back: an `EnvVar` has a readable `value`, a `Secret` has no `value` field at all.

```python theme={"theme":"github-dark"}
boxd.env.set("MODE", "production", scope="all")
boxd.env.list()                    # [EnvVar(name, scope, value)]
boxd.env.delete("MODE", scope="all")

boxd.secrets.set("API_TOKEN", "s3cr3t", scope="shared")
boxd.secrets.list()                # [Secret(name, scope)] — no values
boxd.secrets.delete("API_TOKEN", scope="shared")
```

`set`, `delete` and `move` each hand back the server's confirmation message as a `str`.

Scope decides which machines a value reaches:

| Scope     | Applies to                                  |
| --------- | ------------------------------------------- |
| `private` | only your own machines in that organization |
| `shared`  | the organization's shared machines          |
| `all`     | every machine in the organization           |

`move` changes the scope. It needs `from_scope` as well as `to_scope`, because the same name can exist in several scopes at once:

```python theme={"theme":"github-dark"}
boxd.secrets.move("API_TOKEN", from_scope="private", to_scope="all")
```

It is a move between two places, not a field update, so calling it twice fails the second time. Env vars and secrets share one name space per scope, so an existing env var can block a secret moving into that scope, and vice versa.

Pass `org="acme"` to any of these to work in an organization instead of your personal scope.

## Organizations

```python theme={"theme":"github-dark"}
orgs = boxd.orgs.list()
orgs[0].slug         # the org's unique key — pass this wherever a method takes `org`
orgs[0].name         # display label
orgs[0].is_admin
orgs[0].is_default   # your default org
```

## API keys

```python theme={"theme":"github-dark"}
key = boxd.api_keys.create("ci", org="acme")
key.api_key      # shown once — store it now
key.expires_at   # None = no expiry

boxd.api_keys.list()   # id, name, key_prefix, created_at, last_used_at, expires_at, org, kind
boxd.api_keys.delete(key.id)
```

Pass `expires_in` (seconds) for a key that expires. Every key belongs to exactly one organization: `org` names it, and omitting `org` uses your own. `kind="member"` (the default) acts as you within that organization; `kind="org"` is limited to the organization's shared machines and requires an org admin to create.

<Warning>
  `create` is the only time the raw key is returned. `list` shows the prefix only. Deleting a key takes effect immediately.
</Warning>

## Account

```python theme={"theme":"github-dark"}
from pathlib import Path

me = boxd.account.get()
me.user_id
me.display_name           # None falls back to user_id
me.pubkey_fingerprints
me.billing.max_vms              # your effective quota
me.billing.subscription_status  # None = never subscribed

boxd.account.link_ssh_key(Path.home().joinpath(".ssh/id_ed25519.pub").read_text())

cfg = boxd.account.config()
cfg.default_image
cfg.zone
```

`link_ssh_key` takes the verbatim contents of a `.pub` file and lets you [SSH to your machines](/reference/ssh) with it. Pass `device_id=` to keep one key per device — re-linking from the same device replaces that device's key instead of accumulating stale ones.

## Errors

```python theme={"theme":"github-dark"}
from boxd import (
    BoxdError,               # base class — catch this to catch everything
    AuthenticationError,     # no usable credential, or it was rejected
    PermissionDeniedError,   # authenticated, but not allowed
    NotFoundError,           # no such resource
    ConflictError,           # already exists, or the resource is in the wrong state
    RateLimitError,          # quota or rate limit reached
    APIStatusError,          # any other error returned by the server
    APIConnectionError,      # the request never reached the server
)

try:
    boxd.machines.get("nope")
except NotFoundError:
    ...
```

Every error carries `.message`, `.code` (the canonical status name, e.g. `"not_found"`) and `.grpc_code` (the numeric [status code](https://grpc.github.io/grpc/core/md_doc_statuscodes.html)) for finer-grained handling:

```python theme={"theme":"github-dark"}
import grpc

try:
    boxd.machines.create("my-machine")
except BoxdError as e:
    if e.grpc_code == grpc.StatusCode.RESOURCE_EXHAUSTED.value[0]:
        ...   # hit your quota — surface a 'wait or upgrade' path
    raise
```

## Update notifications

The SDK prints a one-time note to stderr if a newer release is available:

```
A new version of boxd is available (v0.2.0, you have v0.1.9). Update with:
  pip install --upgrade boxd
```

It fires at most once per process and never causes a request to fail. The installed version is available as `boxd.__version__`.

## Reference

<Columns cols={2}>
  <Card title="CLI" icon="https://mintcdn.com/azin/Ax1V0serIwQf0x_2/images/icons/command.svg?fit=max&auto=format&n=Ax1V0serIwQf0x_2&q=85&s=6c33d9e29e4e937c0950311233ec5659" href="/reference/external-cli" width="16" height="16" data-path="images/icons/command.svg">
    Same API, accessed from the terminal. Useful for one-offs and shell scripting.
  </Card>

  <Card title="TypeScript SDK" icon="https://mintcdn.com/azin/Ax1V0serIwQf0x_2/images/icons/typescript.svg?fit=max&auto=format&n=Ax1V0serIwQf0x_2&q=85&s=64245fab67d3a1e63744bc4e6c1f955b" href="/reference/typescript-sdk" width="16" height="16" data-path="images/icons/typescript.svg">
    The same surface for Node, Bun, and Deno.
  </Card>
</Columns>
