Skip to content

Boxes over SSH

A box is one isolated machine — a Firecracker microVM or a Docker container — that you reach over plain SSH. hopboxd is the daemon that spawns and shepherds them.

sh
ssh box@box.hopbox.dev

That spawns a box and drops you into a root shell, greeting you with a short banner — the box's name, image, resources, live lifecycle status, and where its guide lives (~/SKILL.md). The box is named after itself, so the prompt reads root@<box> (e.g. root@proj) — you always know which box you're in. No signup, no client to install, no pre-created box — your SSH key is your identity and the username is the box spec.

Your key is your identity

There is no account to create. The front door accepts any SSH key and derives the owner from its fingerprint:

  • Reconnect with the same key → you get the same box back (within its lifetime).
  • A different key asking for the same box name is refused while that box is alive — names are per-owner, so your proj and someone else's proj are distinct.
sh
ssh proj@box.hopbox.dev          # spawn/attach your box "proj"
ssh proj@box.hopbox.dev          # later — same key, same box

The username is the spec

The SSH username is a small grammar that picks the box name, image, flavor, and lifetime:

name[:image[:flavor[+duration]]]
sh
ssh proj@host                    # box "proj", default image
ssh proj:ubuntu-22.04@host       # pick a catalog image
ssh proj:debian-12:big+1h@host   # image + flavor, stay alive 1h after disconnect
ssh proj+@host                   # force a fresh box (trailing +)
ssh images@host                  # list the image catalog (spawns no box)
ssh flavors@host                 # list the sizing tiers (spawns no box)

Flavors are resource tiers — tiny, small, default, big, huge (1–4 vCPU, 256 MiB–4 GiB). ssh flavors@host lists them; a box with no flavor uses the daemon default. The full grammar is in SSH & the front door.

Files in and out

The front door proxies your session into the box's own SSH server, so scp, sftp, and rsync work like any SSH host — and ssh proj@host "cmd" is a clean, pipe-friendly exec (no pty). Paths are relative to the box home:

sh
scp -r ./src proj@box.hopbox.dev:src      # copy a tree in
scp proj@box.hopbox.dev:out.tgz .         # copy results out
rsync -az ./ proj@box.hopbox.dev:proj/    # sync a directory
sftp proj@box.hopbox.dev                  # interactive

VS Code Remote-SSH, JetBrains Gateway — anything that speaks SSH connects to a box directly (ssh proj@host), no extra tooling. Local port forwarding works too (ssh -L 8080:127.0.0.1:8080 proj@host), dialed from inside the box — see Port forwarding.

microVM & the image catalog

With the microVM backend (--compute microvm) every box is a real Firecracker virtual machine — its own kernel, isolated from the host and from other boxes — booting in well under a second from a copy-on-write clone of a catalog image. The CoW disk is durable: it survives suspend/resume, a daemon restart, and a host reboot.

The image catalog lives in --fc-images-dir as <name>.ext4 files, built once with the scripts in build/microvm/:

  • build-rootfs.sh — a pinned Firecracker-CI base (Ubuntu 22.04) with hopbox-agent, box-guest, the in-VM init, and dev tools baked in.
  • build-deboot.sh — debootstraps a full Debian (debian-12) or Ubuntu (ubuntu-22.04) — a real dpkg database, so the box stays apt-extensible.
sh
sudo IMAGE=ubuntu-22.04 OUT_DIR=/opt/hopbox-microvm build/microvm/build-rootfs.sh
sudo DISTRO=debian build/microvm/build-deboot.sh

build/microvm/catalog.sh is the curated set — the canonical definition of what ssh images@host offers. Every image gets the same base tools (git, curl, vim, tmux, …) plus its own toolchain:

ImageOn top of the base
debian-12, ubuntu-22.04python3
pythonpython3 + pip + venv
nodeNode.js (see below)
gogolang-go
claude-code (alias claude)Node.js, build-essential, python3+pip, ripgrep, jq, unzip, and Claude Code baked in — the agent boxes image

Node comes from nodejs.org, not apt. A distro's nodejs package is frozen at whatever the suite shipped (Debian bookworm's is 18) while the JS tools installed on top raise their engine floor underneath it — and npm i -g treats an unmet engine as a warning, so a stale runtime ships an image that looks fine and isn't. The Node-bearing images therefore bake a pinned, checksummed Active LTS tarball into /usr/local, which precedes /usr/bin on PATH. Override the pin with NODE_LTS:

sh
sudo NODE_LTS=24.19.0 build/microvm/catalog.sh node claude-code

A box stays apt-extensible, so anyone who wants a different runtime — nvm, fnm, mise — can install it in their own box and it will shadow the system one normally. The baked version is the floor that makes non-interactive use work: box-guest agent, ssh box@host "cmd", and the MCP tools run without a login shell, so a version manager that lives in a shell rc file would be invisible to them.

Users discover what you built with ssh images@host and select one with ssh name:<image>@host.

The docker backend (--compute docker) needs no kernel or catalog — boxes are containers, any OCI image works, and hopboxd side-loads the agent + box-guest (--agent-bin / --guest-bin). It's the zero-setup default; microVM is the stronger-isolation, suspend/resume option.

Lifecycle: auto-suspend

Boxes auto-suspend when idle. After --idle-timeout (default 2m) a box is suspended to disk — its memory is snapshotted next to the durable CoW disk and compute stops — and your next connection resumes it, kernel, processes, and open TCP sockets intact. On a clean daemon shutdown boxes are drained the same way, so the next start resumes them.

"Idle" means the box is empty and quiet: nobody attached through the front door, no interactive shell open inside it, and load below the threshold. An open shell keeps the box awake however quiet it is, so sitting at a prompt thinking never suspends it.

The window is deliberately short. Suspending is cheap, resuming is sub-second, and a request to a box's proxied port resumes it before forwarding — so a box that sleeps two minutes after you leave is not a box you have to think about.

Nothing is lost on disconnect; you reconnect straight back into the same box. A box that is never reconnected to is eventually idle-reaped (--idle-reap, default 3h) to bound storage — unless you've marked it durable, in which case it persists (it still auto-suspends when idle; it's just never reaped). You tune all of this per box from inside it:

sh
box-guest durable on         # persist this box (never idle-reaped) — verified only
box-guest auto-suspend off   # keep the box running (never suspend) — verified only
box-guest keep-alive 2h      # hold it alive for a while (e.g. a long detached job)
box-guest idle 15m           # change this box's idle-suspend timeout

Durable and always-on are verified-account privileges.durable on (persist a box) and auto-suspend off (never suspend) are refused for unverified keys — either would let a free key pin a box that never goes away — and keep-alive is capped at 1h, enough for a detached job. Verify (ssh cli@<host> acc register), then mark the boxes you want to keep — from inside (box-guest durable on) or from the fleet (ssh cli@<host> durable <box> on). Durability is per box and off by default, even for verified accounts: nothing persists unless you say so.

Browser-terminal sessions are the exception — a throwaway page's box is ephemeral and reaps a short --grace after the socket closes, so it can't accrue a persisted box.

Box status

You don't track ephemeral / auto_suspend / keep_alive separately — box-guest info and ssh cli@host ls fold them into one status word, combining where the box is with what will happen to it:

StatusMeaning
startingbooting up
runningup; auto-suspends after the idle timeout
suspendedsnapshotted to disk; resumes on your next connection
durablemarked durable — persists (never idle-reaped); still suspends when idle
pinneda keep-alive holds it running until a time — won't suspend
keptauto-suspend off — stays running, never suspends or idle-reaps
ephemerala run box — work that finishes; reaps shortly after disconnect
console
$ box-guest info
box        myproj
image      python
status     running — auto-suspends after 2m idle, resuming on reconnect
resources  2 vCPU · 1024 MiB
load       0.03

keep-alive, auto-suspend off, and asking for a run box are simply what move a running box into pinned, kept, or ephemeral. (box-guest info --json prints the raw fields for scripts.)

Verification / billing

The lifecycle and its controls are available to everyone. Persistence is not — marking a box durable (never idle-reaped) and always-on (auto-suspend off, unlimited keep-alive) require a verified account. Everything else (suspend/resume, per-box idle timeout, short keep-alive) is ungated.

Shutdown drain

Restarting hopboxd (a deploy, a host reboot, systemctl restart hopboxd) doesn't just kill boxes. On SIGTERM the daemon drains: every running, persistent box on a backend that can snapshot (microVM) is suspended — memory + disk — the same as an idle auto-suspend, so the next start resumes it exactly where it was. Snapshots run concurrently, a handful of boxes at a time, and each box gets its own timeout — one wedged snapshot costs that box, not the whole fleet's shutdown budget.

If you're attached with a real terminal (ssh <box>@host, not scp/sftp or a one-off ssh host cmd) when a drain starts, you get a line in your session before anything happens, then a few seconds' grace:

console
hopbox: the host is restarting. This box is being SUSPENDED now — its memory and
disk are snapshotted, and it resumes exactly where you left off. Reconnect in a
moment with:  ssh proj@host

Ephemeral boxes and persistent boxes on a backend that can't snapshot (the docker backend has no suspend/resume) get a different notice, because they aren't coming back the same way — an ephemeral box is gone for good, a docker box is rebuilt from its image with its persistent home intact:

console
hopbox: the host is restarting. This box is EPHEMERAL — it is not snapshotted and
will NOT be restored. Anything you have not pushed or copied out is lost.

hopbox: the host is restarting. This backend cannot snapshot a box, so this one is
rebuilt from its image on restart — your persistent home is preserved, running
processes are not.

The grace period is only spent when someone was actually warned — an unattended fleet restarts at full speed. It doesn't reach every session: the HTTP API's /v1/boxes/{box}/term WebSocket doesn't go through the SSH front door's session registry, so those connections aren't warned — only ssh sessions are.

If the drain still can't save every box it should have (a snapshot timed out, the backend errored), hopboxd logs an ERROR naming the boxes that were not suspended — they cold-boot on the next start instead of resuming, so that line is how an operator knows a shutdown lost work. See Deploy a server for the systemd side that gives the drain room to finish.

Persistent home

The CoW rootfs persists across suspend/resume, but not across a box being destroyed and rebuilt. For files that outlive the box, run the daemon with --homes-dir: each named box gets a persistent home volume (an ext4 image keyed by owner + name) attached at /home/dev — where your shell lands. Your files then survive reap, image upgrade, and reap+respawn:

sh
ssh proj@host 'echo hi > notes.txt'   # lands in /home/dev on the home volume
ssh cli@host rm proj                  # destroy the box
ssh proj@host 'cat notes.txt'         # a fresh box — notes.txt is still there

Anonymous (ssh _@host) boxes get no home — they're throwaway. Clone a box's files into a new one with ssh cli@host clone <src> <dst>. The agent flushes the home to its backing image every ~5s, so an abrupt reap loses at most a few seconds of writes.

Sharing a box

Hand someone a shell in one of your boxes with a share link — a capability token they connect with directly. No account, no key exchange, no pre-created box on their side:

sh
ssh cli@host share proj                    # mint a link for box "proj"
#   ssh hbxs_1a2b…@<host>                   ← give this line to anyone
ssh cli@host share proj --ttl 2h --label "pairing w/ alice"
ssh cli@host shares                         # list your active links (never the token)
ssh cli@host unshare <id>                   # revoke one

Whoever holds the line runs it with any SSH key and lands in a shell in your box — the front door recognizes the token and proxies them in, exactly as it does for you. They get that box only: not your other boxes, and not your fleet commands (rm, rename, share). A link is time-limited (--ttl, default 24h; --ttl 0 never expires) and revocable at any time; the token is shown once and stored only as a hash. Revoking (or expiry) blocks new connections; a session already open on the link keeps running until it disconnects.

A share is full shell access to that box — its files, its running processes, any secrets injected into it. Share a box the way you'd add someone to a repo: it's the whole box, so scope what's in it. Confidential (ssh -J) boxes can't be link-shared — they're reached end-to-end and never terminate at the front door.

Sharing is a verified-account privilege (like durable) — a share is persistent, delegated access to your compute. Verify with ssh cli@host acc register, then share.

box-guest & MCP

Every box ships box-guest, an in-box CLI for the box's own metadata API. There's no credential — the metadata endpoint identifies the calling box by source IP.

sh
box-guest info                 # this box's metadata (load, idle, resources)
box-guest keep-alive 30m       # pin the box alive (no suspend) for a while
box-guest auto-suspend on|off  # toggle auto-suspend for this box
box-guest durable on|off       # persist this box (never idle-reaped) — verified only
box-guest idle 15m             # set this box's idle timeout
box-guest status working "…"   # self-report status (shown in the fleet)
box-guest run make build       # run a command detached; keeps the box alive while it runs
box-guest jobs                 # list detached jobs + their state
box-guest logs <job-id>        # a detached job's output
box-guest skill                # print this box's guide (also dropped at ~/SKILL.md)

Detached jobs. box-guest run <cmd> starts a command in its own session and keeps the box alive while it runs (via keep-alive), so a long task survives the SSH session ending — the box won't suspend or idle-reap until the job's keep-alive expires. box-guest jobs / box-guest logs <id> check on it.

Every box carries a skill guide, and it's dropped as a file so nothing has to be guessed: the agent writes ~/SKILL.md into the home directory at startup, so an AI scouting the box discovers how to operate it — box-guest, the lifecycle, the shared /wrk — without first knowing box-guest skill exists. The guide is the universal box guide plus the image's own specifics; box-guest skill prints the same thing, and it's on the web at hopbox.dev/SKILL.md. ssh images@host shows a one-line summary per image, and AIs read the catalog from the hopbox://images MCP resource.

box-guest mcp runs an MCP server over stdio exposing those as tools — so an AI working inside a box can manage its own sandbox. See The AI-control plane for the fleet-level MCP plane.

Secure by default

  • microVM boxes are hardware-isolated VMs on a dedicated host bridge (--fc-bridge, default hopbox-vmnet; subnet --fc-subnet, default 10.0.0).
  • A box reaches the agent hub and the internet, but not the host's other services, your LAN, or your tailnet.
  • Anonymous boxes are capped (--default-cpus, --default-mem-mb).

Reference

Instant isolated compute — for humans and AIs