Skip to content

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.dev

Why ​

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:

halfwhat it ischanging it
buildthe image: a catalog base plus a scriptneeds a rebuild
defaultswhat a box starts withtakes 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@host lists 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 under set -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.

fieldmeaning
flavorhardware size — must be a known flavor
egressthe egress allowlist — {"domains": [...]}; omit for unrestricted. Edits reach running boxes
lifetimewhat 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
ttlhard 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-dev

show 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.json

The 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-dev

That 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.ext4

What 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.egress says. A build needs distro mirrors that a runtime allowlist will not name, and a fenced apt-get is 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-dev
BUILD  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 ripgrepp

A 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 ago

Nothing 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 deadline

It 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 defaulting

That 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 wins

A 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 typeflavordeadline
box1:go-devthe profile's bigthe profile's 4h
box1:go-dev:smallsmallthe profile's 4h
box1:go-dev:small^30msmall30m

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:

  1. 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.
  2. MCP verbs — profile_build asynchronously, for an AI driving the control plane.

Also planned, later: a workspace naming a default profile, and importing a .devcontainer as a profile.

Instant isolated compute — for humans and AIs