Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

boxd Python SDK

Python SDK for the boxd cloud machine platform. Create machines, run commands in them, move files, and manage everything around them.

Requires Python 3.10+.

Install

pip install boxd

Quick start

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)

Everything follows the same shape: boxd.<resource>.<verb>(id, ...). Resources return plain data — a Machine has fields, not methods.

Client

Boxd()                                     # production
Boxd(api_key="bxd_...")
Boxd(base_url="https://boxd.example.com:9443")   # any other cluster
Argument Environment variable Default
api_key BOXD_API_KEY
token BOXD_TOKEN
base_url BOXD_BASE_URL production
timeout 60 seconds
max_retries 2

There is no environment argument. One base_url selects a cluster and everything else follows from it.

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:

with Boxd(api_key="bxd_...") as boxd:
    ...

Authentication

The first of these that is present wins:

  1. token= — used as given
  2. api_key= — exchanged for a short-lived credential and kept fresh for you
  3. BOXD_TOKEN, then BOXD_API_KEY
  4. running inside a boxd machine — see below
  5. otherwise AuthenticationError

If your key is revoked mid-session, the SDK fails fast with AuthenticationError rather than retrying.

Inside a machine

Inside a boxd machine, Boxd() authenticates automatically — no API key needed, and it talks to that machine's own cluster unless you pass base_url.

from boxd import Boxd

boxd = Boxd()
for machine in boxd.machines.list():
    print(machine.name, machine.status)

One limit: inside a shared machine the automatic credential can manage the organization's shared machines, but cannot read environment variables or secrets, and cannot reach private machines. Pass an API key for those.

Sync and async

Boxd and AsyncBoxd are the same surface — same namespaces, same method names, same arguments, same return types. Switching is await and an import, not a rewrite.

from boxd import AsyncBoxd

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

machine = await boxd.machines.create("my-machine")
result = await boxd.machines.exec(machine.id, "echo hello")
await boxd.close()

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

Machines

machine = boxd.machines.create(
    "my-machine",
    vcpu=4,
    memory="16G",
    env={"MODE": "production"},
)
boxd.machines.get("my-machine")     # by name or id
boxd.machines.list()                     # a plain list
boxd.machines.delete("my-machine")

State:

boxd.machines.start(id)
boxd.machines.stop(id)
boxd.machines.reboot(id)
boxd.machines.pause(id)        # suspend to RAM — fast to resume
boxd.machines.resume(id)
boxd.machines.hibernate(id)    # suspend to disk — cheaper, slower to wake
boxd.machines.wake(id)

Everything else:

boxd.machines.fork("my-machine", "my-copy")     # live clone
boxd.machines.rename(id, "new-name")            # reboots the machine
boxd.machines.share(id)                         # visible to your whole org
boxd.machines.unshare(id)
boxd.machines.set_auto_suspend_timeout(id, 300) # seconds idle; 0 disables
boxd.machines.set_auto_hibernate_timeout(id, 0)
boxd.machines.wait_until_ready(id)
boxd.machines.suggest_name()

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 it again.

The Machine record

Related fields travel together, so you read one object instead of remembering which flat field pairs with which.

machine.id, machine.name, machine.status, machine.image_ref
machine.restart_policy          # str | None
machine.created_at              # datetime | None — None on older machines

machine.resources.vcpu          # what the machine actually got, not what you
machine.resources.memory_bytes  # asked for — always concrete
machine.resources.disk_bytes

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

machine.access.ssh_port         # int | None — None until allocated
machine.access.domain
machine.access.url              # https://<name>.<domain>

machine.idle.suspend_after      # seconds; 0 = that timer is disabled
machine.idle.hibernate_after
machine.idle.destroy_after

machine.source                  # MachineSource | None — None = booted from an image
machine.source.kind             # "fork" | "snapshot"
machine.source.name             # source machine, or snapshot name
machine.source.version          # int | None — snapshots only; a fork has none
machine.source.id               # str | None — provenance; may not resolve

machine.hibernated_at           # datetime | None — None = not hibernated
machine.last_connected_at       # datetime | None — None = never connected
machine.boot_time_ms            # int | None — last boot; None = never booted

None always means "not set": a port that was never allocated, a boot that never happened, an org you do not have. Where 0 is a real answer — a disabled idle timer — it stays 0.

org is the org 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.

source.id points at the machine or snapshot this one came from. It is a record of where the machine came from, not a live link — it may not resolve, and a lookup that finds nothing is normal.

Creating from a snapshot

boxd.snapshots.create(machine_id, "golden")
machine = boxd.machines.create("from-golden", from_snapshot="golden")

Exec

result = boxd.machines.exec(id, "cargo build")
result.stdout      # str
result.stderr      # str — populated for non-PTY execs
result.exit_code   # int
result.success     # bool

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, "cargo build", timeout=30)    # seconds

# Under a PTY, stderr merges into stdout and `stderr` comes back empty.
boxd.machines.exec(id, "top -b -n1", tty=True, cols=120, rows=40)

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

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)

iter_chunks() tags each slice with 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.

For a headless one-shot that reads stdin (jq, cat, claude -p), pass close_stdin=True so it sees end-of-input immediately instead of hanging. Combining it with tty=True raises ValueError — a shell needs stdin open.

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

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()))

Logs

for chunk in boxd.machines.logs(id):
    print(chunk.decode(errors="replace"), end="")

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

Files

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

Ports and proxies

boxd.machines.ports.expose(id, 8080)                  # public TCP forward
boxd.machines.ports.expose(id, 5353, protocol="udp")
boxd.machines.ports.unexpose(id, 8080)
boxd.machines.ports.list()                            # every forward you own

ports.list() is account-wide — pass a machine to narrow it, or filter on .machine_id / .machine_name.

boxd.machines.proxies.create("my-machine", "api", 3001)  # api.<machine>...
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].machine_id
boxd.machines.proxies.set_port("my-machine", 3000, name="api")
boxd.machines.proxies.set_port("my-machine", "auto")  # default route, auto-detected
boxd.machines.proxies.delete("my-machine", "api")

These take an id or a name, like everything else on machines.

Checkpoints

Per-machine captures, restored in place. They are deleted with the machine.

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

Environment variables and secrets

Two namespaces with identical methods. The difference is that a secret's value is write-only — the server never returns it, and the Secret model has no value field at all.

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 value
boxd.secrets.delete("API_TOKEN", scope="shared")

set, delete and move each return the server's human-readable confirmation of what it did.

Scope decides which machines a name applies to:

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

Scope is part of a name's identity — the same name can exist in several scopes at once — so changing it is a move between two addresses, and both ends are required:

boxd.secrets.move("API_TOKEN", from_scope="private", to_scope="shared")

Calling it twice fails the second time. Environment variables and secrets share one namespace within a scope, so an environment variable can block a secret of the same name moving in, and vice versa.

Snapshots and disks

boxd.snapshots.create(machine_id, "golden")   # re-saving bumps the version
boxd.snapshots.get("golden")
boxd.snapshots.list()
boxd.snapshots.delete("golden")

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)

A Snapshot carries both created_at (the first capture) and updated_at (the most recent one — re-saving under the same name bumps the version). A Disk carries created_at and a status of "creating", "ready" or "destroyed"; it can only be attached once it is "ready".

Organizations, credentials, billing, account

orgs = boxd.orgs.list()              # a plain list; each org has `is_default`

key = boxd.api_keys.create("ci", org="acme")
key.api_key                          # the raw key — shown once, store it now
boxd.api_keys.list()
boxd.api_keys.delete(key.id)



me = boxd.account.get()
me.user_id, me.display_name, me.pubkey_fingerprints
boxd.account.link_ssh_key(open("~/.ssh/id_ed25519.pub").read())
boxd.account.config()                # default image, cluster zone

Errors

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,
    ConflictError,           # already exists, or fights the current state
    RateLimitError,          # rate limit or quota
    APIStatusError,          # any other error from the server
    APIConnectionError,      # could not reach the server
)

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

Every error carries .message and .code (the canonical status name, e.g. "not_found").

Connection failures are retried with exponential backoff, max_retries times. Timeouts are never retried — the server may already have applied the request — and neither is AuthenticationError.

Update notices

The SDK prints a one-time note to stderr if the server reports a newer release:

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

Development

cd sdk/python
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"

pytest                                     # unit tests
bash scripts/compile_proto.sh              # regenerate stubs after an API change

Download files

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

Source Distribution

boxd-0.2.0.dev38.tar.gz (78.4 kB view details)

Uploaded Source

Built Distribution

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

boxd-0.2.0.dev38-py3-none-any.whl (61.6 kB view details)

Uploaded Python 3

File details

Details for the file boxd-0.2.0.dev38.tar.gz.

File metadata

  • Download URL: boxd-0.2.0.dev38.tar.gz
  • Upload date:
  • Size: 78.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for boxd-0.2.0.dev38.tar.gz
Algorithm Hash digest
SHA256 3e119450acdc2a25b39a68b4dfe0d709ae2c09aef043f89f36bd7aa8a2fca35f
MD5 429c340a479f7edd10653190c572c676
BLAKE2b-256 491077c259139b84574c320c5f573b52a4a9ece4794a0e255622f9c3c5e60955

See more details on using hashes here.

Provenance

The following attestation bundles were made for boxd-0.2.0.dev38.tar.gz:

Publisher: publish-sdks.yml on azin-tech/boxd

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file boxd-0.2.0.dev38-py3-none-any.whl.

File metadata

  • Download URL: boxd-0.2.0.dev38-py3-none-any.whl
  • Upload date:
  • Size: 61.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for boxd-0.2.0.dev38-py3-none-any.whl
Algorithm Hash digest
SHA256 73acd4d82699b32655394d9facf693ca4c113491b7795487dfab8fbf10e4f929
MD5 7101d9417408d50405da3a2e5b8d41f0
BLAKE2b-256 93f68b3faeac79e6e0efa23940e9b77162afbfd6dc4efd3c05ee269cc7d5c507

See more details on using hashes here.

Provenance

The following attestation bundles were made for boxd-0.2.0.dev38-py3-none-any.whl:

Publisher: publish-sdks.yml on azin-tech/boxd

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

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