hermes-box-lite — architecture

Isolated, clonable agent boxes (claude-code · codex · hermes · neovim · executor) whose entire state lives in one place you can encrypt and carry around — each with its executor web UI reachable from the host. Runtime: smolvm on Apple Silicon.

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.

Tools per box
5
claude · codex · hermes · neovim · executor
First boot
~7 min
provisions once; instant after
Host port / box
auto
random in 4790–4999
Encrypted backup
65 MB
/data only · AES-256
The one durable thing is /data

Tools 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
Disks are sparse

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 disk

The "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 install vs. state locations
ToolHow it installsCodeState on /data
claude-codenpm global/opt/hermes-box/tooling/node-global~/.claude
codexnpm global/opt/hermes-box/tooling/node-global~/.codex
hermesofficial install.sh (root → FHS)/usr/local/lib/hermes-agent~/.hermes (HERMES_HOME)
neovimapt; set as $EDITOR/usr/bin/nvim~/.config/nvim · ~/.local/share/nvim
executornpm 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 workloadhb-workload, which runs as agent on every start, holds the box open, and (re)starts the executor daemon + socat in a loop.

From the CLI

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
Services come up at boot, not on enter

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

The VM protects the host

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

The box CLI
CommandWhat it does
box new NAMEBoot a fresh Ubuntu box, provision, assign a host port (~7 min)
box ls [--all]List boxes + executor URLs (--all: every smolvm VM)
box open NAMEOpen the box's executor web UI in the browser
box enter NAMELog in as agent, attach tmux "main"
box stop / start NAMEStop / 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 NAMEDelete 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)