Skip to main content
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, domains, env vars, and secrets. It talks to the same API as the CLI, so everything you can do from the terminal you can do from Python. Requires Python 3.10+.

Installation

The SDK prints a one-time note on stderr when a newer release is available. It never causes a request to fail. The installed version is available as boxd.__version__.

Authentication

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), in the console, or with boxd.api_keys.create(). 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, with neither an API key nor configuration:
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.
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.

The client

base_url selects the cluster.
All arguments are keyword-only. base_url accepts an optional scheme that controls TLS: Failed connections are retried up to max_retries times with exponential backoff. Timeouts are never retried, because 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:

Sync and async

Boxd and AsyncBoxd are two top-level classes with the same surface: same namespaces, same method names, same arguments. Switching is await and an import rather than a rewrite. The streaming surfaces differ in shape, since stream_exec returns an AsyncExecStream you drive with async for and awaited write/resize calls, and logs is an async generator.
Use AsyncBoxd when you already have an event loop (FastAPI, asyncio scripts, anyio). Use Boxd everywhere else: scripts, notebooks, Django views.

Commands

Every call is boxd.<namespace>.<verb>(...), and nine namespaces hold the whole surface:

Quick start

The whole loop in one example, create through destroy:
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 rather than a handle, and every operation takes the machine id (or name) as its first argument: boxd.<resource>.<verb>(id, ...).

Machines

Create, inspect, and control machines. The sub-namespaces boxd.machines.files, .ports, .proxies, .checkpoints, and .backups are covered further down in this section.
list() returns a plain list. Pass org="acme" for one organization’s machines, or all_contexts=True for everything you can reach. State:
See Suspend, resume, and hibernate for the difference between stop/start (cold) and pause/resume (warm). Everything else:
rename reboots the machine. It is its own call rather than part of an update() for exactly that reason.
create and fork return once the machine is scheduled rather than once it is usable. Call wait_until_ready before doing anything that depends on it running, and especially before forking it again. It polls for up to 90 seconds by default (timeout=, poll_interval=, both in seconds) and raises the builtin TimeoutError when the deadline passes.

Creating machines

Sizing and lifecycle are flat keyword arguments, with no config object. Every one is optional, and create() boots the default image at the default size. fork(source, name) takes the same sizing and lifecycle keywords plus shared, networks, and isolated, and anything you leave out is inherited from the source, including its networks. isolated is a one-way flag on fork and on create(from_snapshot=...). A fork or restore of an isolated machine is always isolated, and passing isolated=True isolates a copy of a machine that wasn’t. There is no way to remove isolation from a copy, and no setter afterwards. Proxies and disk mounts are set up at create time with two small models:
A snapshot restores the machine that was captured, so create(from_snapshot=...) takes only name, org, isolated, and the sizing keywords. Passing image, env, cmd, restart_policy, shared or networks alongside it raises ValueError rather than silently ignoring them. (networks are replayed from the snapshot; set them afterwards with set_networks.)
Renaming reboots the machine, so it is its own call. Everything else is readable straight off Machine.

Networks

Two machines reach each other when they share at least one network, or when neither has any. Machines with no networks form the org’s default network, so naming a network opts a machine out of that pool. The same rule governs the <name>.boxd DNS name and exec / cp between machines, so what resolves is exactly what is reachable.
set_networks replaces the whole set rather than merging, and [] is a real destination, the default network, rather than a no-op. Networks are labels you pick, with nothing to create first, and they never cross an organization. Changes take effect immediately. isolated sandboxes a machine. It never joins the default network and is never a peer of another isolated machine, and boxd strips the in-VM boxd CLI, the metadata endpoint, and your integrations from it. An isolated machine with no networks therefore has no peers at all, and set_networks is the only lever it has. It is fixed at creation, with no setter. Outbound internet and inbound SSH/HTTPS are unaffected. See Sandboxes.

The Machine object

org is the organization the machine belongs to and is billed to, and shared says whether your teammates can see it. A private machine can still be org-billed, so org set with shared=False is normal rather than a contradiction. See VM sharing. 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:
A list command is shell-quoted for you, while a string is passed through as written. timeout is in seconds and gives up on the call, though 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:
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:
Set cols/rows for anything that draws to the terminal, since an unset size gives the PTY zero geometry and leaves the fallback to the program. 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.

Files

upload takes str or bytes and returns the number of bytes written, and 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. Max 3 per machine.
Re-exposing the same machine port keeps its public port and just updates the protocol, and "both" shares one public port across TCP and UDP.

Proxies

HTTPS routes into a machine. See Proxies. The machine argument takes an id or a name, like everywhere else.
name and port are both required on create, because 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. They are deleted with the machine.
create returns while the capture is still "pending".

Backups

Off-machine disk backups for disaster recovery, on demand or on a schedule. See Disaster recovery. Restoring one rewrites the disk only, not memory or running state.
Pass exactly one of interval_secs or cron to schedule_set; a schedule can’t fire more often than every 15 minutes either way. trigger is independent of the schedule and runs one capture at a time per machine. delete is refused for a backup still being captured or one a newer "dedup" entry still points at; a "dedup" backup restores the same as a "ready" one.

Snapshots

Reusable, named captures. See Snapshots. Boot one with machines.create(from_snapshot=...).
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

size takes a human string ("10G") or a byte count. A disk is always created writable, and read-only is chosen per attachment. A disk can be attached to one machine at a time. status is "creating", "ready" or "destroyed", and 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. The difference is what comes back: an EnvVar has a readable value, a Secret has no value field at all.
set, delete and move each hand back the server’s confirmation message as a str. Scope decides which machines a value reaches: move changes the scope. It needs from_scope as well as to_scope, because the same name can exist in several scopes at once:
It is a move between two places rather than 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.

Egress

Two controls, both enforced outside the machine. See Egress control. A per-machine allowlist of what it may reach. Each call replaces the whole set; [] clears it:
A secret bound to the hosts it may be sent to. The machine holds a bxds_… placeholder; the real value is substituted into requests to those hosts on the way out. domains is the full desired set, and setting the secret again without it makes it a plain secret:

Domains

Bring your own domain instead of name.boxd.sh. create binds the domain and starts verification immediately, so call it only once the DNS records are actually in place. The records to set (an A record plus a wildcard CNAME) and the verification flow are covered in Custom domains.

Organizations

Wildcard domain

A wildcard domain for the whole org: see Custom domains. Setting or clearing requires org admin, and any member can read it.

API keys

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, while kind="org" is limited to the organization’s shared machines and requires an org admin to create.
create is the only time the raw key is returned. list shows the prefix only, and deleting a key takes effect immediately.Key management itself needs a credential from an interactive login (the token boxd auth login stores, or BOXD_TOKEN). A client authenticated with an API key, or running on in-VM auth, gets PermissionDeniedError from api_keys.*, so an API key can never mint more keys.

Account

link_ssh_key takes the verbatim contents of a .pub file and lets you SSH to your machines with it. Pass device_id= to keep one key per device, so re-linking from the same device replaces that device’s key instead of accumulating stale ones. Like key management, link_ssh_key requires an interactive-login credential rather than an API key.

Errors

Errors returned by the server carry .message, .code (the canonical status name, e.g. "not_found") and .grpc_code (the numeric status code) for finer-grained handling. Errors raised client-side, like an exec timeout, leave .code and .grpc_code as None: