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.
Timeouts are in seconds throughout this SDK: the client timeout, the exec timeout, and wait_until_ready. Idle timeouts such as auto_suspend_timeout are in seconds too. 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 ten namespaces hold the whole surface:

Quickstart

The whole loop in one example, create through delete:
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.
delete_self() only works inside a machine, and deletes that machine: it is the SDK form of boxd machine remove --self, with confirm set to the machine’s name. Use it rather than delete(own_id) from inside a machine. See Self-delete. 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. There are four sizes: 1 vCPU / 4 GiB, 2 vCPU / 8 GiB (the default), 4 vCPU / 16 GiB, and 8 vCPU / 32 GiB. memory takes "4G", "8G", "16G", or "32G". Every machine gets a fixed 100 GB disk, so leave disk out. Any other value is refused. fork(source, name) also takes shared, networks, isolated and auto_suspend_timeout, and inherits anything you leave out from the source machine, including its networks. A fork, and a machine created from a snapshot, starts its source’s automations paused so nothing runs twice: the returned Machine has paused_automations set to how many, and the call warns once when it isn’t 0. A fork, and a machine created from a snapshot, keeps its source’s size. vcpu, memory, disk and volumes are refused on both, and auto_suspend_timeout is accepted. Change the size afterwards with resize:
Either vcpu or memory names the whole size. A running machine is rebooted to apply it, a stopped one takes it at its next start, and a machine on standby or hibernated is refused until you resume or wake it. isolated is a one-way flag on fork and on create(from_snapshot=...). A fork of an isolated machine, or a machine created from an isolated snapshot, 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 machine created from a snapshot resumes as it was captured, so create(from_snapshot=...) takes only name, org, isolated, and auto_suspend_timeout. 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. A spec= is also accepted, merged over the snapshot’s own: see Machine specs.
Renaming reboots the machine, so it is its own call. Everything else is readable straight off Machine.

Machine specs

A machine spec describes a machine in one value: what it boots from, its size, labels, networks, isolation, egress, env, the secrets it gets, the integrations and agent logins it needs, and the packages it installs on first boot. Build one from reusable pieces and create from it. See Machine specs for the whole picture.
A builder is immutable: every method returns a new one, so one base can be extended in several directions. Pieces: slack(), jira(), github(), asana() (pass shared="tag" to use the org connection shared under that tag instead of the owner’s own), claude(), codex(), and secrets(*names). Integrations and agent logins are recorded on the machine, and a missing one never blocks the create. A piece is a plain dict ({"labels": {...}}, or {"connections": {"notion": "personal"}} for any integration), so you can write your own. Pieces merge in order: maps (labels, env, connections) merge with the later piece winning, lists (networks, egress, secrets, agents, packages) combine in order without duplicates, isolated only turns on, and base, size or assignTo set twice to different values raises MachineSpecError (a ValueError) naming the field ("2X8" is the same size as "2x8"). So does a key a spec doesn’t have, and a name in both env and secrets. merge_specs(*pieces) applies the same rules to plain pieces and returns the resolved spec. The server checks every field again and names it in its error (spec.size: ...). A spec carries its machine’s base, size, labels, networks, isolation and env, so create(spec=...) takes none of image, env, labels, networks, isolated, vcpu or memory beside it, and raises MachineSpecError if you pass one. A spec whose base is a snapshot, or a team with a default snapshot, restores it. A snapshot keeps its machine’s spec: restoring it gives the new machine that spec, and create(from_snapshot=..., spec=...) merges a spec on top by the same rules. Its size, if set, must be the snapshot’s. A fork keeps its source’s spec. Secrets. secrets(*names) names the secrets the machine gets. Their values stay in boxd.secrets, usually with the unattached scope, which reaches no machine until a spec lists it (see Env vars and secrets). On your own machine a name resolves to your own secret first (private-unattached, then private), else the org’s (unattached, all, shared). On a machine created for someone else, or a shared machine, only the org’s count. Only org owners and admins may list an org unattached secret, and an org shared secret only reaches shared machines, so a private machine can’t list one. A name that resolves to nothing, or that the create may not list, fails the create, naming it. On the machine, the spec’s env and secrets win over your private vars, which win over the org’s shared vars (shared machines only), which win over its all vars. The machine keeps a reference, so rotating a secret reaches it. See Secrets. For someone else. An org owner or admin can create a machine for another member with .assign_to(user_id), the member’s id as orgs.get_members() lists it, and org= set on the create. The machine is the member’s from the start: private to them, billed to the org, set up with their own integrations and agent logins. You can’t reach it afterwards, so create() answers with its id and name only, and can’t also set delete_after. The member sees created_by and created_by_name on it. Refused, naming spec.assignTo: without org, with shared=True, for a caller who isn’t an owner or admin of the org, with an org API key or from inside a machine, for someone who isn’t a member, and with a snapshot or disk the member couldn’t use. Your own id is a plain create, and forks, snapshots and restores drop assignTo.
The machine runs the spec you wrote as the member’s own machine: its packages as root, its env and its base, alongside the member’s GitHub token, agent logins and personal connections. Assign a spec only if the member would run it themselves.
From inside a machine, with its own credentials, a spec can’t list an unattached secret (yours or the org’s) on any machine, update_spec can’t change that machine’s own egress or secrets, and assign_to is refused. Make those changes with an API key or in the console. Names a fork or a restore takes over from its source’s spec are not affected. Limits, checked first and named with what was given (spec.env: at most 128 variables (got 129)): at most 256 KiB as JSON in all; env at most 128 variables, each value at most 32 KiB and all values together at most 64 KiB; secrets at most 64 names; networks at most 16, each name at most 63 bytes; egress at most 256 entries, none empty (leave egress out for no allowlist); connections at most 64; packages at most 200 listed and 50 once duplicates are dropped, each at most 4 KiB (a restore counts the snapshot’s too). An update_spec patch is at most 256 KiB.

Updating a machine’s spec

update_spec changes the spec of a machine created from one and applies what changes. Pass a JSON Merge Patch, or a function that adds to the current spec:
Each change in changes says how it applies (apply): It returns a MachineSpecUpdate: changes (a list of MachineSpecChange), reboot_required, reboot_recommended, spec (after the patch, or what it would be for a dry run), applied (False for a dry run), rebooted and resize_error. A MachineSpecChange has field, apply ("live", "reboot_recommended", "reboot" or "refused"), added, removed, changed (map keys whose value changes; values are never shown), before and after for scalars, a one-sentence note, and warnings for what the change won’t do. A refused change (also: new packages or a retry on a fork, a size change in standby or hibernated) refuses the whole patch, and so does a reboot without reboot=True. Nothing is applied. So is an update during which the spec changed (another update, a label edit): it raises ConflictError, and reading the machine again and retrying is safe. The builder form does that itself: it sends the spec it read, so a change made in between can’t be undone by its lists, and on a conflict reads the spec again and reapplies the function, up to 3 times. Otherwise the spec and the labels, networks, egress and secrets it drives change together, then the size, then the new packages run. If the size then fails (the org’s quota changed, the machine went to standby), the rest stays applied and the call still returns: resize_error says why, and spec has the size the machine has. The patched spec is checked like a create’s. machine.created_spec never changes, so the console shows the spec badge as updated. Allowed to whoever may edit the machine’s labels: its owner, or an org owner or admin on a shared machine. The builder form only adds: a size that differs from the spec’s raises, so use a patch to replace or remove. spec_patch(from_spec, to_spec) computes the patch the builder form sends. AsyncBoxd has the same await boxd.machines.update_spec(...).

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 removes the in-machine boxd CLI, the metadata endpoint, integrations and the run CLI, agent logins, and the laptop bridge 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. It keeps outbound internet, unless an egress allowlist is set, and inbound SSH and HTTPS. See Sandboxes.

Egress allowlist

Limit what a machine can reach on the internet. See Egress allowlist.
The list replaces the whole allowlist, at most 256 entries. Hostnames and wildcards admit HTTP and HTTPS, addresses and CIDRs admit everything else, and the hosts of any host-bound secret the machine holds are always admitted on top. An internationalized name is kept in its ASCII form (bücher.de becomes xn--bcher-kva.de), which is what the machine looks up. A machine restored from a snapshot starts unrestricted, unless its machine spec lists egress.

SSH

A machine has SSH on unless you turn it off. With SSH off, nothing answers SSH for it, on its public address or your org’s network: the connection closes before a byte of SSH is sent, and the SSH sessions open to it are closed. See Turn SSH off.
exec, file transfers, boxd connect and the console’s terminal don’t use SSH and keep working. A fork and a machine created from a snapshot keep their source’s setting unless ssh says otherwise. From inside a machine, on its own credentials, set_ssh can’t change that machine’s setting. SDK releases from before this setting never sent ssh=False to the server, so update the SDK before relying on it.

The Machine object

org is the organization the machine belongs to and is billed to, and shared says whether the rest of the org can see it. A private machine can still be org-billed, so org set with shared=False is normal rather than a contradiction. See Machine sharing. source.id may not resolve if the machine or snapshot it points at was since deleted. A lookup that finds nothing is normal. requirements says, for each connection and agent login the spec needs, whether it is connected now. can_fix is true when you could connect it: the owner for personal connections and agents, an org owner or admin for shared ones. reason says why this machine can’t get one as it is: an isolated machine gets no integrations or agent logins, and a shared machine never gets anyone’s personal connection or agent login. An unmet requirement is only unavailable on the machine. Nothing blocks creating, booting or using it. Connect from the console’s Integrations needed section on the machine. See Integrations the machine needs. packages reports the spec’s first-boot commands, run once, in order, as root. state is "pending", "running", "done", "failed" or "skipped". The first failure stops the rest until a retry (update_spec(id, {}, retry_packages=True)), or until the failed command is removed from the spec, which marks it "skipped" and lets the rest run. In output_tail the value of every secret the machine gets shows as [secret NAME]. list() leaves output_tail empty to keep lists small, and get() has it. See Packages. Models are pydantic, so machine.model_dump() gives you a plain dict. An unrecognized status is passed through rather than rejected, so a machine in a newly added state still prints. The CLI shows standby for the state the SDK calls suspended.

Exec

machines.get, exec, fork and delete accept a machine name or an id. create returns before the machine is running, so call wait_until_ready before the first 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. Directories are managed with the same namespace:
list_dir returns the direct children only, in no particular order, and caps at 5,000 entries. A listing with truncated set holds the first 5,000. Use exec for the rest.

Ports

Raw port forwards: TCP and UDP 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

Subdomain 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 subdomain proxy 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

Named captures of a running machine’s memory and disk. See Snapshots. Create a machine from one with machines.create(from_snapshot=...).
Saving under an existing name replaces the version, and older versions cannot be restored or pinned. 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=. create returns a CreatedSnapshot whose paused_automations is how many of the machine’s automations it captured: every machine created from it starts them paused, and the call warns once when it isn’t 0.

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(...)]. A machine can only mount a disk that lives on its own host, so that is the way to pair a disk with a new machine: the machine is placed on the disk’s host. See Disks.

Env vars and secrets

Two namespaces with identical methods. See Env vars and 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:
list reports the organization’s unattached secrets as unattached and your own as private-unattached, each with bound_machines, the number of machines whose spec lists it (0 for every other scope). unattached is for secrets only: a plain value a machine needs belongs in its spec’s env. See Unattached secrets. 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. A secret can be bound to the hosts it may be sent to, so the machine only ever holds a placeholder. See Host-bound secrets.
domains is the complete set. Setting the secret again without it makes it a plain secret, so pass it again when rotating a value. Pass org="acme" to any of these to work in an organization instead of your default org.

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, and [] clears it:
A secret bound to the hosts it may be sent to. The machine holds a bxds_… placeholder, and the real value is substituted into HTTPS requests to those hosts on the way out, anywhere in the request (the URL, a header, the body). Over plain HTTP it stays the placeholder, and where a bound host’s response repeats the value, the machine gets the placeholder instead. domains is the full desired set, and setting the secret again without it makes it a plain secret:
A machine spec can set the allowlist from birth with .egress(...). From inside a machine, on its own credentials, set_egress_allow can’t change that machine’s own allowlist.

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

Members and invites

See Organizations and teams for the roles. Anywhere org is omitted, your default org is used.
Invites and role changes need org admin. An invite that makes someone an admin, and a promotion to admin, need an interactive login as well, so an API key or an in-machine client gets PermissionDeniedError there. Demoting another admin is owner-only, and the owner cannot be demoted or removed.

Machine defaults

What a create that names no size gets. It is a preference below the quota, not the quota itself. Setting or clearing requires org admin.

Machine limit

How many machines the org may have at once, and asking for more. Any member can read it, with a login or an API key issued for the org. Only the org’s owner can send a request, with their login or an API key of theirs: max_vms is the new total, above the current limit and at most 1000, and message (at most 1000 characters) is optional. One request is open at a time; another raises ConflictError until the limit changes, and the owner gets an email when it does. Anyone else gets PermissionDeniedError.

Wildcard domain

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

Teams

A team is a group of members inside an org, with an optional default snapshot that new team machines are created from. See Organizations and teams.

Usage and cost

What each machine used and cost, and what was actually charged. Org owners and admins only. An API key issued for the org works too. The CLI equivalents are boxd manage usage, boxd machine usage and boxd manage billing activity. See Commands.
To record what a machine cost after deleting it, wait for complete. Metering lags by up to about 25 minutes, so the figure read right after delete() is still growing.
orgs.usage() carries the same flag on every machine, so one periodic call can settle all of them. A machine that still exists is never complete in a window that runs to now. Money is in micro-units (1e-6) of the org’s currency. Usage is metered every second and settled every 15 minutes. The usage figures are priced at the org’s current rates, so they are an estimate that tracks billing activity closely. Billing activity is what was charged: one usage_charge per 15 minutes for the whole org, plus top-ups and grants. Machines deleted since still show up, with status "deleted". A window spans at most 366 days. orgs.billing_activity_page() returns one page with its next_page_token when you want to page by hand. The async client has the same methods (async for e in boxd.orgs.billing_activity(): ...).

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 default org. 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. list() returns the keys you made and, in every organization you administer, that organization’s kind="org" keys, whoever made them. An org key keeps working after its maker leaves the organization, so its admins can see it and delete() it. created_by names the maker, and mine is True for your own keys.
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-machine 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, for example "not_found") and .grpc_code (the numeric status code) for finer-grained handling. Errors raised client-side, like an exec timeout or a machine spec that doesn’t merge (MachineSpecError, also a ValueError), leave .code and .grpc_code as None: