updateSpec.
That makes a spec the way to hand someone a machine that is ready to work. Describe what every engineer on a team needs once (the team’s base snapshot, GitHub, Claude Code, an npm token, a few packages), add what one person needs on top, and create the machine, for yourself or for them.
Specs are available in the TypeScript and Python SDKs and the gRPC API. The CLI does not take a spec. The console shows a machine’s spec, what it still needs connected and how its packages went, but does not edit specs.
A complete example
An org owner describes what every engineering machine gets once, then builds Mike’s machine on top of it:mike-dev boots from the team’s snapshot, joins the eng network, gets NODE_ENV and NPM_TOKEN, installs nginx on its first boot, and records that it needs the org’s shared GitHub and Jira connections, the owner’s own Slack, and a Claude Code login. To make it Mike’s machine rather than yours, add .assignTo(mikesUserId) (see Create a machine for someone else).
As data, mike.resolve() (no machine is created) is:
updateSpec patches it. A spec holds secret names, never values.
Build a spec
Machine.spec() starts an empty builder. Every method returns a new builder, so one base like engineering can be extended in several directions without the extensions affecting each other.
Pieces
A piece is part of a spec. The SDKs come with these:
Integrations and agent logins are recorded on the machine as what it needs. A missing one never blocks the create. See Integrations the machine needs.
Write your own piece
A piece is a plain object (adict in Python) holding spec fields, so a team’s own pieces are ordinary code:
"personal" or "shared:<tag>". The create refuses an integration boxd can’t connect, naming it (spec.connections.<toolkit>: ...), and accepts the GitHub App (github-app) only as "shared:org". Agents are "claude" and "codex".
How pieces merge
Pieces merge in the order they are added:- Maps (
labels,env,connections) merge key by key, and the later piece wins. - Lists (
networks,egress,secrets,agents,packages) combine in order, without duplicates. isolatedonly turns on. A later piece can’t turn it off.base,sizeandassignTocan be set by several pieces only to the same value. Two different values are an error. Sizes compare as boxd stores them, so"2X8"is the same as"2x8".
env and secrets. The SDKs check this as you build: TypeScript throws a BoxdError, Python raises MachineSpecError (a ValueError), each naming the field. mergeSpecs(...pieces) (Python merge_specs(*pieces)) applies the same rules to plain pieces and returns the resolved spec.
The server checks every field again when you create the machine, and its errors name the field and the rule, such as spec.size: ... or spec.packages[2]: ....
Create a machine from it
Pass the builder, or a resolved spec, asspec:
create takes none of image, env, labels, networks, isolated or the sizing options beside it. The other options still apply (name, org, shared, the idle timeouts, delete after), within what a restore allows when the base is a snapshot.
A spec whose base is a snapshot, or a team with a default snapshot, restores that snapshot, so the machine comes up running whatever was captured. A snapshot keeps the spec of the machine it was taken from. Restoring it gives the new machine that spec, and a spec you pass is merged on top by the same rules as pieces. Its size, if it names one, must be the snapshot’s, since a restore keeps its size.
Read the spec back
Both are
null (None) on a machine that wasn’t created from a spec and didn’t inherit one from a snapshot or a fork. A machine’s spec lists the labels and networks the machine really has, including those it took over from a snapshot or a fork source.
Secrets
A spec lists the secrets its machine gets by name. The values stay in boxd’s secret store and never appear in a spec. Usually they are unattached secrets: a secret with scopeunattached reaches no machine by itself, only the machines whose spec lists it.
boundMachines (bound_machines) on a listed secret is how many machines list it in their spec. See Env vars and secrets for the scopes.
Which secret a name means. A name resolves from the most private to the most shared:
Who may list what. Only org owners and admins may list an org
unattached secret. A personal secret never goes on a shared machine or on a machine created for someone else. An org shared secret only reaches shared machines, so a private machine can’t list one. A name that resolves to nothing, or to a secret the create may not list, fails the create, naming it.
Listing a private, all or shared secret changes nothing about who gets it, since it already reaches the machine through its scope. The spec records that the machine needs it, and the create checks that it exists.
Precedence on the machine. The spec’s env and its secrets win over your private vars, which win over the org’s shared vars (shared machines only), which win over the org’s all vars. A name can’t be in both env and secrets.
A reference, not a copy. Rotating a secret reaches every machine that lists it: new shells and exec sessions get the new value. Deleting the secret takes it away from them, and setting it again under the same name brings it back. An unattached secret can be bound to hosts like any other, and the machine then holds its placeholder.
Forks, snapshots and restores resolve the names again for the new machine and its owner, and fail naming any that no longer resolve.
Create a machine for someone else
An org owner or admin can create a machine for another member of the org: add.assignTo(userId) (.assign_to(user_id)) with the member’s user id, as orgs.getMembers() (orgs.get_members()) lists it.
personal connections and agents in the spec are theirs to connect. Only org secrets count for it (Secrets).
You can’t see or reach the machine afterwards, as with any member’s private machine. So create answers with its id and name only, and the same create can’t also set a delete-after timer. The member sees who created it: createdBy (your user id) and createdByName (created_by, created_by_name) on the machine, and a Created by line on the machine’s page in the console.
The create is refused, naming spec.assignTo:
- without
org, or withshared: true(the machine is private to the member); - when you aren’t an owner or admin of the org;
- with an org API key, or from inside a machine (use your login or a member API key);
- for someone who isn’t a member of the org;
- with a snapshot or a disk the member couldn’t use themselves.
assignTo is never inherited: forks, snapshots and restores of the machine drop it.
Integrations the machine needs
A spec’sconnections and agents are the machine’s requirements. Reading the machine (get, list, or what create returns) says, for each one, whether it is connected right now:
canFix (can_fix) says whether you could connect it: the owner for personal connections and agents, an org owner or admin for shared ones. Connect from the console: the machine’s page has an Integrations needed section with a Connect button on each one you can connect. For a shared connection you can’t, it says to ask an org admin to connect it, under its name.
An unmet requirement is only unavailable on the machine. Nothing blocks creating, booting or using it. Some requirements can’t be met on the machine as it is, and reason says why:
- An isolated machine gets no integrations or agent logins.
- A shared machine never gets anyone’s personal connection or agent login. Require
shared:<tag>instead. See Where connections are available.
Packages
packages are shell commands the machine runs once, in order, after its first boot:
- as root, with bash (sh on an image without it);
- with
DEBIAN_FRONTEND=noninteractiveand no stdin, so nothing waits for input; - with the env vars and secrets an
execsession on the machine gets; - with a 30-minute timeout each.
/var/log/boxd/packages.log in the machine, which only root can read. A command is done when its shell exits, so one that leaves a process running in the background (my-server &) doesn’t hold up the next. The machine doesn’t auto-suspend or hibernate while a command runs.
The first failure (a non-zero exit or a timeout) stops the rest. The machine stays usable, and you have two ways on:
- Run it again. Fix the cause, then run the failed command and every one after it again with
retryPackages, or with Retry in the console’s Packages section. - Give up on it, or replace it. Remove it from the spec with
updateSpec. A command that leaves the spec before it finished is skipped, and the ones after it run. So replacing a wrong command is one patch: drop it and add the corrected one.
Secrets in the output. In
outputTail, the value of every secret the machine gets is shown as [secret NAME]. Only the value as it is: a value printed encoded or in pieces isn’t recognised. The log file isn’t masked. It is root’s, and root on the machine can read its env anyway.
Interruptions. A command the platform interrupts (the machine stops, reboots, hibernates or moves to another host, or boxd restarts on its host) goes back to "pending" and runs again from the start once the machine is running, and the rest follow. After 3 interruptions it fails. Since a command can run more than once this way, write commands that are safe to repeat: apt-get install -y is, appending to a file is not.
What finished never runs again: not on a reboot, a resume, a wake, or a move.
Restores and forks. Restoring a snapshot marks done the commands that had already run on the machine the snapshot was taken from, and runs the rest of the snapshot spec’s commands (one that was running when the snapshot was taken runs again from the start), then the ones your spec adds. A fork keeps its source’s package states and runs nothing.
Egress. Commands that download something need the hosts in the spec’s egress, if it has one: *.ubuntu.com for apt-get on Ubuntu, registry.npmjs.org for npm.
At most 50 commands (a restore counts the snapshot’s too), each at most 4 KiB. The server names the one it refuses (spec.packages[2]: ...). Put a longer script in a file and run that.
Update a machine’s spec
updateSpec (update_spec) changes the spec of a machine created from one, and applies what changes. Pass a patch, or a function that adds to the current spec:
size that differs from the spec’s is an error, and nothing is removed. Use a patch to replace or remove. specPatch(from, to) (spec_patch) computes the patch the builder form sends.
Each change in changes names its field and says how it applies (apply):
A change also lists what was
added, removed and changed (map keys only: values are never shown), before and after for a scalar, a one-sentence note on what happens, and warnings for what won’t. The response’s rebootRequired and rebootRecommended (reboot_required, reboot_recommended) sum it up, spec is the spec after the patch, and applied and rebooted say what happened.
reboot: true only allows a reboot. It doesn’t reboot the machine for env or secrets: call machines.reboot() for that.
All or nothing. A refused change refuses the whole patch, and so does a change that needs a reboot without reboot: true. Also refused: new packages or a retry on a fork, and a size change for a machine on standby or hibernated. Nothing is applied. The patched spec is checked like a create’s, so an unknown integration, a secret you may not list, or more than 50 commands in all are refused too, naming the field.
Someone else changed it. If the spec changes while your update runs (another update, a label edit), nothing is applied and the call throws ConflictError. Reading the machine again and retrying is safe. The builder form does this itself: it sends the spec it read, and on a conflict reads the spec again and reapplies your function, up to 3 times. A patch you built from a spec you read can say so too, with expectedSpec: machine.spec (expected_spec=machine.spec): if the spec is no longer that one, nothing is applied and the call throws ConflictError.
In order. 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: resizeError (resize_error) says why, and spec has the size the machine has.
createdSpec never changes, so the console’s spec badge reads UPDATED after a change. An update is allowed to whoever may edit the machine’s labels: its owner, or an org owner or admin on a shared machine. A machine without a spec can’t get one through updateSpec.
From inside a machine
An SDK running inside a machine with the machine’s own credentials can create machines from specs and update them, with three limits. They keep a machine from loosening its own confinement:- It can’t change that machine’s own
egressorsecretswithupdateSpec(not even in a dry run), or its allowlist withsetEgressAllow. - It can’t list an unattached secret, yours or the org’s, in any spec: not on a create, a restore’s own spec, or an
updateSpecon another machine. Names a fork or a restore takes over from its source’s spec are fine. - It can’t create a machine for someone else (
assignTo).
Forks, snapshots and restores
- A snapshot keeps its machine’s spec, as it stands when the snapshot is saved. A machine restored from it gets that spec, with anything you pass merged on top.
- A fork keeps its source’s spec.
- Labels and networks. The new machine’s spec lists the labels and networks it really has: those of the snapshot or the source, plus its own.
- Egress. A restore normally starts with no allowlist, but one whose spec lists
egressgets that allowlist. A fork inherits its source’s allowlist either way. See Egress control. - Secrets are resolved again for the new machine and its owner. A name that no longer resolves fails the create, naming it and where it came from.
assignTois never inherited.- Packages: see Restores and forks above. A fork can’t take new packages or a retry.
Limits
Checked before anything else, and named with what was given (spec.env: at most 128 variables (got 129)):
In the console
- The SPEC badge. A machine created from a spec carries a SPEC badge in the machine list and on its page. Hover or tap it to see the current spec as YAML (secret names only). Once the spec has changed since the machine was created, the badge reads UPDATED, and a Current / As created toggle shows either one.
- Created by. A machine created for someone else says who created it for whom.
- Integrations needed. Each connection and agent login the spec needs, with Connect for the ones you can connect, or why it can’t be met on this machine. Once all are met, one line says so.
- Packages. Each command with its state and how long it took. A failed command shows its exit code or reason and its output, and the section says where it stopped, with Retry for whoever may update the machine’s spec.
- Secrets page. Unattached secrets are a scope you can pick, and each row says how many machines use it. See Env vars and secrets.
updateSpec from an SDK or UpdateMachineSpec on the gRPC API.