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:
token=— used as givenapi_key=— exchanged for a short-lived credential and kept fresh for youBOXD_TOKEN, thenBOXD_API_KEY- running inside a boxd machine — see below
- 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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3e119450acdc2a25b39a68b4dfe0d709ae2c09aef043f89f36bd7aa8a2fca35f
|
|
| MD5 |
429c340a479f7edd10653190c572c676
|
|
| BLAKE2b-256 |
491077c259139b84574c320c5f573b52a4a9ece4794a0e255622f9c3c5e60955
|
Provenance
The following attestation bundles were made for boxd-0.2.0.dev38.tar.gz:
Publisher:
publish-sdks.yml on azin-tech/boxd
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
boxd-0.2.0.dev38.tar.gz -
Subject digest:
3e119450acdc2a25b39a68b4dfe0d709ae2c09aef043f89f36bd7aa8a2fca35f - Sigstore transparency entry: 2313324800
- Sigstore integration time:
-
Permalink:
azin-tech/boxd@34d99ba565b2cba0e8e78be5f7827f21c2a5b807 -
Branch / Tag:
refs/heads/dev - Owner: https://github.com/azin-tech
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-sdks.yml@34d99ba565b2cba0e8e78be5f7827f21c2a5b807 -
Trigger Event:
workflow_dispatch
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
73acd4d82699b32655394d9facf693ca4c113491b7795487dfab8fbf10e4f929
|
|
| MD5 |
7101d9417408d50405da3a2e5b8d41f0
|
|
| BLAKE2b-256 |
93f68b3faeac79e6e0efa23940e9b77162afbfd6dc4efd3c05ee269cc7d5c507
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
boxd-0.2.0.dev38-py3-none-any.whl -
Subject digest:
73acd4d82699b32655394d9facf693ca4c113491b7795487dfab8fbf10e4f929 - Sigstore transparency entry: 2313324973
- Sigstore integration time:
-
Permalink:
azin-tech/boxd@34d99ba565b2cba0e8e78be5f7827f21c2a5b807 -
Branch / Tag:
refs/heads/dev - Owner: https://github.com/azin-tech
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-sdks.yml@34d99ba565b2cba0e8e78be5f7827f21c2a5b807 -
Trigger Event:
workflow_dispatch
-
Statement type: