Skip to main content
The boxd Python SDK gives you programmatic access to the full boxd API β€” create and manage machines, exec commands, stream output, transfer files, manage proxies, templates, disks. It talks to the same public gRPC API as the CLI, so everything you can do from the terminal you can do from Python. Sync API by default (blocking, context-manager-friendly), with a parallel async API under boxd.aio.

Install

pip install boxd
Requires Python 3.10+.

Quick start

from boxd import Compute

with Compute(api_key="bxk_...") as c:
    box = c.box.create(name="my-vm")
    result = box.exec("echo", "hello")
    print(result.stdout)
    box.destroy()

Authentication

Compute(api_key="bxk_...")     # API key (recommended)
Compute(token="eyJ...")        # direct JWT
Compute()                      # reads BOXD_API_KEY or BOXD_TOKEN
API keys are minted via boxd keys create (CLI), the boxd.sh console UI, or directly via the gRPC API. The SDK itself only issues short-lived JWTs (c.token.create()), not long-lived API keys β€” see Tokens.

Configuration

By default, Compute connects to production. For custom or self-hosted endpoints, override URLs explicitly:
Compute(
    api_key="bxk_...",
    api_url="http://my-boxd.example.com:9443",
    exchange_url="https://my-boxd.example.com/api/v1/auth/token",
)

Environment variables

All Compute arguments can be supplied via env vars. Constructor args win over env vars.
VariableSetsDefault
BOXD_API_KEYAPI key (long-lived, recommended)β€”
BOXD_TOKENDirect JWT (short-lived)β€”
BOXD_API_URLgRPC endpointhttp://boxd.sh:9443
BOXD_EXCHANGE_URLToken-exchange URLhttps://boxd.sh/api/v1/auth/token
api_url accepts an optional URL scheme that controls TLS:
api_url valueTransport
http://host:portplaintext (scheme stripped before connecting)
https://host:portTLS (scheme stripped before connecting)
bare host:portTLS, except localhost / 127.* which stay plaintext
The default http://boxd.sh:9443 matches production. Self-hosted clusters can pass api_url="http://my-cluster:9443" to opt into plaintext.

Machine lifecycle

box = c.box.create(name="my-vm")
boxes = c.box.list()
found = c.box.get("my-vm")                             # by name or id
forked = c.box.fork("my-vm", name="f1")

box.start()
box.stop()
box.reboot()
box.destroy()
s = box.suspend()    # SuspendResult
r = box.resume()     # ResumeResult
See Suspend & resume for the difference between stop/start/reboot (cold) and suspend/resume (warm).

Box fields

Box always carries server-returned fields, but which ones are populated depends on how it was obtained:
Fieldcreateforklistget
id, name, image, public_ip, statusβœ“βœ“βœ“βœ“
url, boot_time_msβœ“βœ“NoneNone
forked_fromNoneβœ“NoneNone
restart_policy, disk_bytes, auto_suspend_timeout_secsNoneNoneNoneβœ“
If you need the URL after a list / get round-trip, the https://<name>.boxd.sh form is stable, or call box.proxies(). If you need the lifecycle fields off a Box from list / create / fork, re-fetch via c.box.get(box.name).

BoxConfig

create, fork, and template.create_vm all take an optional config:
from boxd import BoxConfig, LifecycleConfig

config = BoxConfig(
    vcpu=2,                           # default 2
    memory="4G",                      # default "8G"
    env={"API_KEY": "secret"},        # env vars exposed to the VM
    restart_policy="always",          # "always" | "never"
    lifecycle=LifecycleConfig(
        auto_suspend_timeout=300,     # idle network secs; 0 disables
        auto_destroy_timeout=0,       # total lifetime secs; 0 disables
    ),
)

box = c.box.create(name="my-vm", config=config)

Exec

# Simple β€” collect all output
r = box.exec("python", "script.py")
r.stdout       # str (subprocess fd 1)
r.stderr       # str (subprocess fd 2)
r.exit_code    # int
r.success      # bool β€” exit_code == 0

# With env vars and timeout
box.exec("sh", "-c", "echo $FOO", env={"FOO": "bar"}, timeout=30)

# Streaming β€” proc is an ExecProcess. iter_stdout / iter_stderr are
# separate sync generators. wait() returns the exit code; close() force-
# terminates (idempotent).
proc = box.exec("cargo", "build", stream=True)
for chunk in proc.iter_stdout():
    print(chunk.decode(), end="")
# (read iter_stderr concurrently in another thread if you want warnings live)
exit_code = proc.wait()
proc.close()

# Headless one-shot β€” close stdin immediately so commands like
# `claude -p`, `jq`, `cat file` see EOF and don't hang waiting for input.
r2 = box.exec(
    "claude", "-p", "summarize this PR",
    stream=True, close_stdin=True,
)

# Interactive (PTY + stdin) β€” stdin must stay open for user input.
# close_stdin=True is REJECTED with this combination (ValueError).
sh = box.exec("bash", interactive=True)    # interactive implies pty
sh.stdin.write(b"echo hello\n")
sh.stdin.write_eof()
print(sh.wait())

Streams: stdout vs stderr

r.stderr / proc.iter_stderr() is populated for non-PTY execs. The subprocess’s fd 2 is delivered separately from fd 1 β€” useful when a tool’s progress lives on stderr while the answer is on stdout (e.g. codex exec, cargo build). Under pty=True / interactive=True, the kernel TTY layer merges stderr into stdout (that’s how terminals work), so everything arrives on stdout and the stderr side stays empty. Set pty=False if you need the split.

close_stdin

Pass close_stdin=True (only valid with stream=True on a non-PTY exec) to have the SDK close the client send half of the bidi stream right after the command starts. Headless one-shots that read stdin (claude -p, jq, cat file) see EOF immediately and proceed; without it they hang for several seconds (or forever) waiting on stdin. Passing it together with pty=True or interactive=True raises ValueError rather than silently dropping the flag β€” a PTY shell needs stdin open for user input.

PTY size + resize

For PTY/interactive execs, pass cols and rows to set the initial terminal geometry, and call proc.resize(cols, rows) to update it mid-session when the local terminal changes size. This is what makes TUI apps like claude, vim, htop render at the right width.
import os, signal, shutil

cols, rows = shutil.get_terminal_size()
proc = box.exec("claude", stream=True, pty=True, cols=cols, rows=rows)

def _on_resize(_signum, _frame):
    c, r = shutil.get_terminal_size()
    proc.resize(c, r)
signal.signal(signal.SIGWINCH, _on_resize)

for chunk in proc.iter_stdout():
    os.write(1, chunk)
Zero / unset cols and rows fall back to the server default of 80Γ—24. resize() on a non-PTY exec is a harmless no-op.

File transfer

from pathlib import Path

box.write_file(b"binary content", "/app/file.bin")
box.write_file("text content", "/app/file.txt")
box.write_file(Path("local/file.py"), "/app/file.py")
data = box.read_file("/app/output.json")    # bytes
Paths on the VM side are absolute or relative to /home/boxd. Max 100 MB per transfer.

Proxies

Every box is live at https://name.boxd.sh forwarding to port 8000 by default. Additional subdomain proxies forward to other ports.
box.proxies()                              # list[Proxy]
proxy = box.create_proxy("api", port=3001) # api.<vm>.boxd.sh -> port 3001
box.set_proxy_port(port=3000)              # change default proxy port
box.set_proxy_port(port=3001, name="api")  # change a named proxy
box.delete_proxy("api")

Logs

# Snapshot of available console output
for chunk in box.stream_logs():
    print(chunk.decode(errors="replace"), end="")

# Follow (keeps the stream open for new chunks)
for chunk in box.stream_logs(follow=True):
    print(chunk.decode(errors="replace"), end="")

Templates

Reusable image + BoxConfig frozen together.
from boxd import BoxConfig

t = c.template.create(
    name="t1",
    image="ghcr.io/org/img:tag",
    config=BoxConfig(vcpu=2, memory="4G"),
)
c.template.list()

# create_vm accepts a Template object OR a template ID string. Pass an
# optional `config` to override the template's defaults (e.g. bump
# memory for one specific VM).
box = c.template.create_vm(template=t, name="from-t")
big = c.template.create_vm(
    template=t.id,
    name="from-t-big",
    config=BoxConfig(memory="16G"),
)
c.template.delete(t.id)

Disks

Independent persistent volumes you can attach and detach from boxes.
d = c.disk.create("data", size="10G")
d.id; d.name; d.size_bytes; d.status

# attach / detach take a Box instance OR a name/id string
d.attach(box, mount_path="/mnt/data")
d.attach("my-vm", mount_path="/mnt/data", read_only=True)
d.detach("my-vm")

d.destroy()

# list returns DiskHandle instances β€” same methods as above
for d in c.disk.list():
    print(d.name, d.status)

Domains

Custom HTTPS domains bound to a box. DNS must already point at the boxd proxy β€” see HTTPS.
c.domain.bind("app.example.com", box)            # accepts a Box, name, or id
c.domain.bind("app.example.com", "my-vm")
for d in c.domain.list():
    print(d.domain, "->", d.vm_id)
c.domain.unbind("app.example.com")

Networks

Custom L2 networks for VM-to-VM communication.
n = c.network.create()              # server assigns id
named = c.network.create(name="staging")

# `create` returns the new network's id only β€” `subnet` and `status` come
# back populated once provisioning settles. Re-fetch via `list` to read them.
for net in c.network.list():
    print(net.id, net.subnet, net.status)

Tokens

Issue scoped JWTs for delegated access. The raw token string is only returned at creation β€” store it then.
t = c.token.create(expires_in=3600)   # 0 = server default
t.token         # str β€” "eyJ..."  save this; list() will not return it again
t.expires_at    # int β€” unix seconds

# list() returns TokenInfo (no raw token; listing-safe metadata).
# The `jti` field here is what revoke() takes β€” there's no jti on
# the freshly-created Token, so revoke goes through list().
for info in c.token.list():
    info.jti          # str β€” used by revoke()
    info.created_at   # int β€” unix seconds
    info.expires_at   # int β€” unix seconds
    c.token.revoke(info.jti)

# Use the token to authenticate a new client
c2 = Compute(token=t.token)

Identity

me = c.whoami()
me.user_id              # "gh-username"
me.fingerprints         # ["SHA256:..."]
me.default_network_id   # "net-..."

cfg = c.config()
cfg.default_image       # "ubuntu:latest"
cfg.zone                # "boxd.sh"
The package also exposes its installed version:
import boxd
print("on", boxd.__version__)

Errors

from boxd import (
    BoxdError,            # base class
    AuthenticationError,
    NotFoundError,
    QuotaExceededError,
    InvalidArgumentError,
    TimeoutError,
    ConnectionError,
    InternalError,
)

try:
    box = c.box.get("nope")
except NotFoundError:
    ...
ClassgRPC status
AuthenticationErrorUNAUTHENTICATED, PERMISSION_DENIED
NotFoundErrorNOT_FOUND
QuotaExceededErrorRESOURCE_EXHAUSTED
InvalidArgumentErrorINVALID_ARGUMENT, ALREADY_EXISTS
TimeoutErrorDEADLINE_EXCEEDED
ConnectionErrorUNAVAILABLE
InternalErrorINTERNAL, UNKNOWN
Each error carries the underlying grpc_code (numeric gRPC status β€” see grpc.StatusCode) for finer-grained handling:
import grpc

try:
    c.box.create(name="my-vm")
except BoxdError as e:
    if e.grpc_code == grpc.StatusCode.RESOURCE_EXHAUSTED.value[0]:
        ...   # hit per-user quota
    raise

Update notifications

Every gRPC response carries an x-boxd-py-sdk-latest header set by the boxd proxy. The SDK’s interceptor compares it to the installed version and prints a one-time sys.stderr line if a newer release is available:
A new version of boxd is available (v0.1.2, you have v0.1.1). Update with:
  pip install --upgrade boxd
The notice fires at most once per process, never causes a request to fail, and is silent if the proxy isn’t advertising a newer version. Compares as PEP 440-ish (numeric prefix, then per-component compare on .devN suffixes).

Sync vs async

The default boxd.Compute is the sync API β€” fully blocking, safe for scripts, REPLs, notebooks, Django views, anywhere you don’t already have an event loop. It wraps the async implementation behind a dedicated background loop, so you don’t pay for asyncio setup yourself.
from boxd import Compute             # sync β€” recommended default
boxd.aio.Compute is the async API β€” use it from inside an existing event loop (FastAPI, asyncio scripts, Quart, anyio):
from boxd.aio import Compute

async with Compute(api_key="bxk_...") as c:
    box = await c.box.create(name="my-vm")
    result = await box.exec("echo", "hello")
Both APIs are implemented against the same async core. Pick whichever fits your codebase:
from boxd import Computefrom boxd.aio import Compute
Call stylec.box.create(...)await c.box.create(...)
Context managerwithasync with
Best forscripts, CLIs, notebooksFastAPI, async workers, concurrent fan-out
The sync API wraps the async one with a dedicated event loop, so you can call it from synchronous code without surprises. Pick boxd unless you already have an event loop.

Reference

https://mintcdn.com/azin/Ax1V0serIwQf0x_2/images/icons/command.svg?fit=max&auto=format&n=Ax1V0serIwQf0x_2&q=85&s=6c33d9e29e4e937c0950311233ec5659

CLI

Same API, accessed from the terminal. Useful for one-offs and shell scripting.
https://mintcdn.com/azin/Ax1V0serIwQf0x_2/images/icons/moon-stars.svg?fit=max&auto=format&n=Ax1V0serIwQf0x_2&q=85&s=58518b62c4c44197c707ca00e1fd628e

Suspend & resume

box.suspend() / box.resume() and how auto-suspend interacts with them.