Appearance
Box profiles
A box profile is your own named box configuration: what image a box starts from, what gets installed on it, and what defaults it starts with.
sh
ssh cli@box.hopbox.dev profile set go-dev < go-dev.json
ssh cli@box.hopbox.dev profile build go-dev
ssh box1:go-dev@box.hopbox.devWhy
You can pick from the image catalog and apt install inside a box afterwards. What you cannot say today is "my box always starts with these tools, configured this way" — every new box re-does it, and a persistent home does not help, because the toolchain lives in the image rather than in /home.
A profile is where you say it once.
The definition
json
{
"build": {
"base": "ubuntu-22.04",
"script": "apt-get update && apt-get install -y ripgrep jq"
},
"defaults": {
"flavor": "big",
"egress": { "domains": ["github.com", "proxy.golang.org"] },
"lifetime": "work",
"ttl": "4h"
}
}Two halves, because they behave differently:
| half | what it is | changing it |
|---|---|---|
build | the image: a catalog base plus a script | needs a rebuild |
defaults | what a box starts with | takes effect on the next box, no rebuild |
That split is the reason a profile is one object rather than several. Adding a domain to egress should not rebuild a gigabyte image — and if it did, you would stop editing your allowlist, which is the last thing a fence needs.
build
base(required) — a catalog image name (ssh images@hostlists them).script— runs as root inside a throwaway box of your own when the profile is built, and the resulting disk becomes the profile's image. One script, not a list of steps: the image is captured once at the end, so there are no per-step layers to cache and pretending otherwise would be a lie about what happens. It runs underset -e, so the first failing command fails the build — an image quietly missing what the profile promised is the failure nobody notices until they use it.
Running as root there is not a privilege you are being granted — it is the same root you already have in your own box, and the box is the isolation boundary. That is why a build can run your script at all.
defaults
Every field is something you could already put in a box spec, and anything explicit in the spec wins. A profile sets the default; it never overrides what you asked for.
| field | meaning |
|---|---|
flavor | hardware size — must be a known flavor |
egress | the egress allowlist — {"domains": [...]}; omit for unrestricted. Edits reach running boxes |
lifetime | what the box is for — run · work · durable · always (see lifecycle); a box spawned from the profile is that lifetime, and durable / always need a verified account |
ttl | hard deadline as a duration string, e.g. "4h" |
Working with them
sh
ssh cli@host profile set go-dev < go-dev.json # store (JSON on stdin)
ssh cli@host profile ls # PROFILE BASE BUILD UPDATED
ssh cli@host profile show go-dev # the definition, canonicalised
ssh cli@host profile build go-dev # compile it into an image (streams; minutes)
ssh cli@host profile builds go-dev # build history + which one is current
ssh cli@host profile rm go-devshow prints what set would accept, so editing round-trips:
sh
ssh cli@host profile show go-dev > go-dev.json
$EDITOR go-dev.json
ssh cli@host profile set go-dev < go-dev.jsonThe definition arrives on stdin, and that is deliberate. The CLI runs on the server, so a --file flag would read a path on the host rather than on your machine — the wrong file, and a way to read the daemon's own files. Shell redirection does the reading on your side, which is both safer and the same keystrokes.
Profiles are yours. They are scoped to your key's account, so your go-dev and someone else's are different things, and nobody else can see or use yours.
How many you may have depends on your tier — a profile is a standing claim on host disk, since each keeps up to three built images. A free key gets 5; a verified account has no limit. Editing a profile you already have always works, even at the limit.
Building one
sh
ssh cli@host profile build go-devThat is a foreground command that takes minutes, and it streams the script's output while it runs:
==> build go-dev#1: box build-go-dev-1 from ubuntu-22.04
==> running build script as root
Get:1 http://archive.ubuntu.com/ubuntu jammy InRelease [270 kB]
Setting up ripgrep (13.0.0-2ubuntu0.1) ...
==> syncing
==> capturing image /opt/hopbox-microvm/images/profiles/9f86d081884c/go-dev-0001.ext4
==> built go-dev#1
built go-dev#1 -> /opt/hopbox-microvm/images/profiles/9f86d081884c/go-dev-0001.ext4What happens, in order: a throwaway box is spawned from build.base, your script runs in it as root, the box is stopped, and its disk is flattened into an image. Then the box is destroyed — on every path, including a failure.
A few things about that build box are worth knowing, because they are choices rather than accidents:
- It counts against your box quota, and a build at the limit is refused saying so. A box that did not count would be the hole that makes a quota meaningless.
- It gets none of your secrets. Whatever a build touches is baked into an image that outlives every box made from it, so a build-time credential is a credential living on in a reusable artifact. If a build seems to need one, it belongs at runtime instead, where secrets already work and stay off the disk.
- Its network is unrestricted, whatever
defaults.egresssays. A build needs distro mirrors that a runtime allowlist will not name, and a fencedapt-getis a miserable thing to debug. With no secrets in the box, there is nothing there to leak. - It is bounded — 30 minutes by default. A build that runs over is failed and its box destroyed, so a script waiting on something that never comes cannot hold a box forever.
Builds are numbered, and never replaced
Each build is the next one, with its own image:
sh
ssh cli@host profile builds go-devBUILD STATUS BASE STARTED TOOK
#3 failed ubuntu-22.04 2m ago 41s
#2 ok (current) ubuntu-22.04 1h ago 3m12s
#1 ok ubuntu-22.04 2d ago 3m20s
#3 failed:
build script failed (exit 100)
E: Unable to locate package ripgreppA profile name resolves to the newest succeeded build. That one rule is what makes two bad days harmless:
- A build in flight is not current, so nothing can boot a half-written image.
- A failed build is not current either. A typo in your build script leaves yesterday's image serving instead of taking your fleet down.
Failed builds are kept, with the tail of their output. It is the only record of why a profile will not build — deleting it would make a broken profile look like one nobody ever built.
A build interrupted by a daemon restart is marked failed on the way back up, and its box removed. A record stuck at "building" would be indistinguishable from one still running.
Old builds are reclaimed
The newest 3 succeeded builds of a profile keep their image; older ones are freed, and their row stays marked reclaimed so you can still see what was built and when. Three, because rolling back to what worked yesterday is only possible while yesterday's image exists — and because an account rebuilding daily should not be able to fill the host.
A build a box is still running from is never freed, whatever its age. A box's disk is copy-on-write over its image file, so deleting that file would not reclaim a build — it would break a box.
profile rm deletes the definition and leaves its builds to be reclaimed the same way, once nothing is booting from them. That is why removing a profile does not disturb boxes already spawned from it.
When the base image moves
profile ls shows which build a spawn would use, and flags one whose base catalog image has been rebuilt since:
PROFILE BASE BUILD UPDATED
go-dev ubuntu-22.04 #2 (base changed) 3d ago
tools debian-12 #1 1h ago
scratch ubuntu-22.04 - 2m agoNothing ever rebuilds on its own. That would change what a box gets without anyone asking, which is exactly the reproducibility that numbering builds bought. But a profile quietly sitting on a base with an unpatched CVE is a real problem — so it is reported and you decide. Running boxes are untouched either way; profile build <name> when you want the new base.
Where builds are not available
Building captures a box's whole disk as an image, which only the microVM backend can do. On a Docker-backed host, profile build says so plainly rather than failing halfway through.
Spawning from one
A profile goes where an image name goes:
sh
ssh box1:go-dev@host # box "box1" from your go-dev profile
ssh box1:go-dev:big^2h@host # ...at the big flavor, 2h deadlineIt replaces the image slot rather than adding a segment to the grammar. That is not a shortcut: a profile is already the named thing that carries an image and its defaults together, so giving it a slot of its own would be the second namespace profiles exist to remove. The slot resolves to a catalog image or one of your profiles, and yours wins a clash — your go-dev is the more specific thing, and it should not silently stop being yours the day an operator adds an image by that name.
A workspace can name one
Set it once and every box in that workspace starts from it:
sh
ssh cli@host ws profile acme go-dev # boxes in "acme" start from go-dev
ssh acme/api@host # ...including this one
ssh cli@host ws profile acme - # stop defaultingThat is what stops a project's boxes each having to name the same profile. The precedence is explicit spec > workspace default > nothing, so a box that names something still gets what it asked for:
sh
ssh acme/api@host # the workspace's go-dev
ssh acme/api:ubuntu-22.04@host # a plain catalog image — yours winsA workspace pointing at a profile you have since deleted refuses the spawn and says which workspace pointed there, rather than failing with a name you never typed.
Anything you state explicitly wins over the profile's defaults:
| you type | flavor | deadline |
|---|---|---|
box1:go-dev | the profile's big | the profile's 4h |
box1:go-dev:small | small | the profile's 4h |
box1:go-dev:small^30m | small | 30m |
The box records which build it came from, so "which environment produced this?" stays answerable after a rebuild — and a rebuild never changes what a running box got.
A profile you have not built cannot be spawned
ssh box1:go-dev@host on a profile with no successful build is refused, and says to build it. It does not fall back to the catalog: a profile that plainly exists reporting "unknown image" would send you looking in the wrong place.
Validation
A definition is checked when you store it, not when it is used — an unknown flavor, an unrecognised lifetime, an unparseable duration, an egress block that allows nothing, or a mistyped JSON key is refused with the reason.
That strictness is the point: the alternative is a profile that saves cleanly and fails later, at a build or a spawn, when whoever typed it has moved on. A key like "defalts" is rejected rather than ignored, because a profile that silently does not say what it appears to say is worse than one that will not save.
From an AI
The same profiles are drivable over the MCP plane: profile_set, profile_build, profile_list, profile_rm, with hopbox://profiles carrying what each resolves to.
profile_build there is asynchronous — it returns the build number immediately and the build runs on. There is deliberately no streaming variant: the build already runs in a box, so its log is a box's output, and one story for "a long thing running in a box" beats two. The outcome arrives on hopbox://profiles (latest / status / message), and the build box is visible on hopbox://fleet while it works.
An AI spawns from a profile the same way you do — by putting its name where an image goes: box_delegate {image:"go-dev"} or fleet_apply {boxes:[{image:"go-dev"}]}.
What's missing
Landing next, in order:
- Staleness and cleanup — old builds reclaimed once no box uses them, and drift reported when the base catalog image moves underneath a profile. Nothing ever rebuilds on its own: that would change what a box gets without anyone asking, which is exactly the reproducibility that numbering bought.
- MCP verbs —
profile_buildasynchronously, for an AI driving the control plane.
Also planned, later: a workspace naming a default profile, and importing a .devcontainer as a profile.