Overview
Each box is a self-contained smolvm virtual machine with the agent tools pre-installed in sane places. You create boxes, accumulate state in them over time, reach each box's executor web UI from the host on its own port, and pack any box's state into a single encrypted file you can move anywhere. The host filesystem is untouchable from inside a box, so you can run many side by side.
/dataTools are reconstructable (re-run provisioning). State — auth, sessions, hermes memory, executor db, your workspace — lives entirely under /data, and that is the only thing save / load moves.
The layers
┌──────────────────────────────────────────────────────────┐
│ hermes-box-lite the box CLI + provision/ + guest/ │ you run ./box …
│ defines the recipe and the lifecycle │
├──────────────────────────────────────────────────────────┤
│ smolvm 1.0.4 VM create/start/stop · machine cp │
│ port forwarding · isolation │
├──────────────────────────────────────────────────────────┤
│ Apple Virtualization.framework native arm64 hypervisor │
├──────────────────────────────────────────────────────────┤
│ macOS · Apple Silicon │
└──────────────────────────────────────────────────────────┘
smolvm boots the Ubuntu 24.04 OCI image (native arm64 — no emulation) as a real VM. There is no daemon and no Docker; each box is an independent VM with its own disks and its own forwarded host port.
Where everything is stored
Two places matter: the per-box VM directory on the Mac, and the filesystem inside the box.
On the host
macOS host
├─ ~/Developer/hermes-box-lite/ the project (CLI + recipe)
│ ├─ box · box.env the CLI and its config
│ ├─ provision/provision.sh what runs inside each new box
│ ├─ guest/ (profile.sh · hb · hb-workload …) files copied into the box
│ ├─ .boxes registry: "name⇥host-port"
│ └─ backups/-.tar.gz.gpg encrypted /data archives
│
└─ ~/Library/Caches/smolvm/vms// ← one dir per box
├─ storage.raw 20 GiB sparse · ~4.5 GB used ← the box's whole filesystem
├─ overlay.raw 10 GiB sparse · nearly empty
└─ agent.sock · agent.pid · name · vm.lock
The disks show a 20 GiB / 10 GiB apparent size but only consume what's written. Everything in the box (tools and /data) lives inside storage.raw.
Inside the box
inside the VM (single rootfs · ~3.9 GB used)
├─ /opt/hermes-box/
│ ├─ tooling/node-global/bin/ claude · codex · executor
│ ├─ tooling/uv/ uv
│ ├─ bin/hb in-box control helper
│ └─ bin/hb-workload persistent service (boot loop)
├─ /usr/local/lib/hermes-agent/ hermes code (Python venv)
├─ /usr/local/bin/{hermes,nvim} hermes launcher · neovim
│
└─ /data/ ← DURABLE STATE · what save/load moves
├─ home/agent/.hermes/ auth · sessions · memories · skills · config.yaml
├─ home/agent/.claude .codex
├─ home/agent/.executor/ executor db/secrets (agent-owned)
├─ home/agent/workspace/ your files
└─ logs/ executor.log · socat.log
/data is a directory, not a separate diskThe "data disk is the box" idea is conceptual here. /data is a folder in the one rootfs; save tars its contents. Portability comes from the encrypted archive, not from moving a disk.
The tools
All install during box new. Code lands in system paths (rebuildable); state lands on /data (durable, portable).
| Tool | How it installs | Code | State on /data |
|---|---|---|---|
| claude-code | npm global | /opt/hermes-box/tooling/node-global | ~/.claude |
| codex | npm global | /opt/hermes-box/tooling/node-global | ~/.codex |
| hermes | official install.sh (root → FHS) | /usr/local/lib/hermes-agent | ~/.hermes (HERMES_HOME) |
| neovim | apt; set as $EDITOR | /usr/bin/nvim | ~/.config/nvim · ~/.local/share/nvim |
| executor | npm global; daemon + web UI + MCP | /opt/hermes-box/tooling/node-global | ~/.executor |
HOME for the agent user is /data/home/agent, so every tool's dotfiles land on /data automatically. Hermes is an interactive TUI (you run hermes); executor is auto-wired into claude/codex as an MCP server, and its web UI is reachable from the host (next section).
Executor on the host
Each box runs executor on its own internal 127.0.0.1:4788. box new assigns a unique random host port in 4790–4999 (checked against the registry and the host) and forwards it to the box, so you can run many boxes and open each one's web UI from the Mac.
Mac browser http://localhost:4903 ─┐
│ smolvm forward host:4903 → guest:4903
▼
box "alpha" socat 0.0.0.0:4903 ──► 127.0.0.1:4788 executor daemon
(the bridge) (localhost-only)
box "beta" socat 0.0.0.0:4915 ──► 127.0.0.1:4788 …its own port, no collision
The bridge exists because executor's persistent daemon binds localhost only, while smolvm forwards host→guest on the guest interface, not loopback. A socat listener on 0.0.0.0:<port> closes that gap. Keeping it alive is the job of the box's smolvm workload — hb-workload, which runs as agent on every start, holds the box open, and (re)starts the executor daemon + socat in a loop.
box ls lists every box with its http://localhost:<port>; box open <name> launches it in your browser. Set EXPOSE_EXECUTOR_WEB=0 for localhost-only boxes (no host port).
Lifecycle
box new alpha
│ smolvm machine create --image ubuntu -p PORT:PORT --
│ workload = run hb-workload as agent (waits until provisioned)
│ provision.sh (~6-8 min): tools → /opt + /usr/local, socat, /data skeleton
│ → workload starts: executor daemon (127.0.0.1:4788) + socat (0.0.0.0:PORT)
▼
alpha (provisioned, running, executor reachable at http://localhost:PORT)
│
├─ box ls each box + its executor URL
├─ box open alpha open the executor web UI in the browser
├─ box enter alpha login as agent → tmux "main" (executor already up)
├─ box stop / start state persists in storage.raw; workload restarts services
├─ box save alpha tar /data → machine cp out → gpg → backups/*.gpg
├─ box load FILE bx fresh box (provision + new port) → restore /data
└─ box rm alpha delete the VM + disks, drop from registry
Because the executor daemon + socat are run by the box's workload, they're up whenever the box is running — you don't have to box enter for the host URL to work. The workload also re-launches them if they die.
Save / load / extract
Moving a box around is moving its /data. All three commands use the same robust mechanism (a guest-side tar moved with machine cp, since streaming large data through machine exec corrupts it).
save: guest: tar /data → host: machine cp out → gpg (AES-256) → backups/*.tar.gz.gpg
load: host: gpg -d → machine cp in → guest: tar x into /data (after create + provision)
extract: host: gpg -d → tar x into backups/-extracted/ (files only, no VM)
Encryption never invokes gpg's pinentry: the passphrase comes from BOX_PASSPHRASE or a /dev/tty prompt and is fed via --passphrase-fd in loopback mode.
Design decisions
Provision per instance, not a packed golden image
smolvm's pack create --from-vm captures installed tools as the VM overlay, which on machine create --from expands toward the full disk allocation (~10 GB) and fails to instantiate. So each box boots clean Ubuntu and provisions once.
Host access via workload + socat
Getting executor reachable from the host took ruling out the alternatives: executor's persistent daemon is localhost-only; its 0.0.0.0 mode is a temporary foreground runtime that self-terminates; machine exec-started daemons die on disconnect; and --init runs only once (first start). The smolvm workload command, by contrast, runs every start and stays alive — so it hosts a localhost executor daemon plus a socat 0.0.0.0:<port> bridge behind the host forward.
No systemd
smolvm runs the OCI image container-style, so there's no init system; the workload loop is the supervisor. Hermes is interactive and isn't a service.
State corralled under /data
Setting the agent's HOME and tool env (HERMES_HOME, CLAUDE_CONFIG_DIR, CODEX_HOME, XDG) onto /data means one tar captures everything that matters and nothing that doesn't.
Security boundary
No host directory mounts; the host filesystem is unreachable from inside a box. The only inbound surface is the one executor port you forward, bound to localhost on the Mac. Run as many boxes as you like, isolated from each other and the host.
Honest limits, same as the full Hermes Box design: tools inside a single box are not isolated from each other or from the passwordless-sudo agent user — the boundary is the VM. The socat bridge binds 0.0.0.0 inside the (isolated) VM, reachable only via the forward you added. Backups contain credentials and private work, so they are encrypted by default. There is no checksum-pinned reproducible rebuild or transactional rollback; the bet is "re-run provisioning if something breaks."
Command reference
| Command | What it does |
|---|---|
| box new NAME | Boot a fresh Ubuntu box, provision, assign a host port (~7 min) |
| box ls [--all] | List boxes + executor URLs (--all: every smolvm VM) |
| box open NAME | Open the box's executor web UI in the browser |
| box enter NAME | Log in as agent, attach tmux "main" |
| box stop / start NAME | Stop / start (state persists; workload restarts services) |
| box save NAME [out] | Encrypt /data → portable .tar.gz.gpg |
| box load FILE [name] | Build a fresh box (new port) and restore an archive into it |
| box extract FILE [dir] | Decrypt + untar to a host dir (inspect; no VM) |
| box rm NAME | Delete the VM and its disks |
- Runtime
- smolvm 1.0.4 · Apple Virtualization.framework
- Base image
- ubuntu:24.04 (native arm64)
- Project
- ~/Developer/hermes-box-lite
- Per-box VM data
- ~/Library/Caches/smolvm/vms/<hash>/
- Host port range
- 4790–4999 (random per box)