Appearance
HTTP API
hopbox serves a versioned HTTP API when the daemon is started with --api-addr. It is the same identity over a different transport: a request authenticates with an API key that maps to your SSH key fingerprint (the box owner), and every operation is scoped to you — the API can do exactly what ssh cli@host and ssh <box>@host can, no more.
It is live at https://box.hopbox.dev/v1 (TLS-fronted); self-hosters front --api-addr with any TLS proxy. GET /healthz is unauthenticated; everything else needs a key.
Authentication
Keys are minted only over SSH (never over HTTP):
sh
ssh cli@box.hopbox.dev apikeys create laptop # prints hbx_<40 hex> ONCE
ssh cli@box.hopbox.dev apikeys ls # NAME · CREATED · LAST USED
ssh cli@box.hopbox.dev apikeys rm laptop # revokeThe secret is shown once and stored only as a SHA-256 hash. Send it as either:
Authorization: Bearer hbx_…
X-Api-Key: hbx_…A missing, unknown, or revoked key is 401 {"error":…}.
No silent failures
Every failure is a 4xx/5xx with a JSON {"error":"…"} body. No endpoint ever returns 200 with empty output to hide an error.
Endpoints
| Method + path | Behavior |
|---|---|
GET /healthz | 200 {"ok":true}. No auth. |
GET /v1/boxes | Your boxes as data: [{id,name,image,state,age_s,cpus,mem_mb}]. state is the same word cli ls shows. |
DELETE /v1/boxes/{name} | Remove a box. 204, or 404 {"error":…}. |
POST /v1/boxes/{name}/suspend · /resume | 200 {"state":…}, or 400 {"error":…} (e.g. the docker backend can't suspend). |
GET /v1/images · GET /v1/flavors | The catalogs as JSON (name + description; flavors add cpus, mem_mb). |
POST /v1/exec/{spec} | Run a command in a box, spawning/attaching on demand. See below. |
GET /v1/boxes/{box}/term | WebSocket — a live PTY into a box you own (attaches; auto-resumes if suspended; never spawns). Auth the hbx_ key (?key= ok) before upgrade. Client: binary = keystrokes, text {"resize":{"cols","rows"}}; raw pty output streams back as binary. Powers lilypad's terminal. |
ANY /v1/boxes/{box}/proxy/{port}/{path…} | Reverse proxy onto a TCP port inside a box you own — reach the app it is serving with no tunnel. Any method; request and response stream through. Attaches the box (no idle-suspend mid-session) and auto-resumes it. 502 {"error":…} when nothing is listening on that port. See below. |
GET /v1/mcp | WebSocket — the whole AI-control plane for browsers & third parties (the SSH cli@host mcp path needs an SSH key; this needs only your hbx_ key). Upgrades to a per-owner MCP session: the fleet, box.*/fleet.*, wrk.*, ws.*, secret.*, surfaces, and live push. See below. |
GET /v1/wrk/{path} | Your shared workspace (/wrk) over HTTP. A directory → JSON {"dir":true,"path":…,"entries":[{name,is_dir,size}]}; a file → its raw bytes. 404 {"error":…} if missing; 503 if the workspace isn't enabled. |
PUT /v1/wrk/{path} | Write a file from the request body. A trailing slash makes a directory (mkdir -p, parents created). 201 new / 204 overwrite. |
DELETE /v1/wrk/{path} | Remove a file, or a directory recursively. 204; 404 if absent. |
Add ?ws=<name> to any /v1/wrk request to target a specific workspace's drive (e.g. ?ws=acme); omit it for your default workspace. An invalid name is 400.
{spec} and {name} accept the full front-door grammarname[:image[:flavor]][^ttl] — so POST /v1/exec/build:go spawns a fresh Go box named build if you don't have one, and POST /v1/exec/build:go^30m gives that box a hard deadline: it is torn down 30 minutes after it started, whatever it is doing. Note that timeout_s below bounds the command; ^ttl bounds the box, and is enforced by the daemon rather than by the connection, so it survives your process dying.
sh
H='Authorization: Bearer hbx_…'
curl -H "$H" https://box.hopbox.dev/v1/boxes
curl -H "$H" -X POST https://box.hopbox.dev/v1/boxes/proj/suspend
curl -H "$H" https://box.hopbox.dev/v1/flavors
curl -H "$H" -X PUT --data-binary @notes.md https://box.hopbox.dev/v1/wrk/notes.md
curl -H "$H" https://box.hopbox.dev/v1/wrk/ # list your /wrkPOST /v1/exec/{spec}
Run a command in a box. The box is spawned/attached on demand from {spec}.
Request — Content-Type: application/x-hopbox-exec is a single JSON header line, a newline, then raw bytes piped to the command's stdin until EOF:
{"cmd":"wc -c","env":{"FOO":"bar"},"timeout_s":300}
<stdin bytes…>Header fields: cmd (required); env (extra env, merged over the box's session env; keys must be shell identifiers); timeout_s (default 300 — kills the command, not the box). For a command with no stdin, a text/plain body is the command verbatim.
Response — streaming by default (text/event-stream):
event: stdout
data: <base64 chunk>
event: stderr
data: <base64 chunk>
: keepalive (a comment every 15s)
event: exit
data: {"code":0}stdout/stderr are base64 so binary output survives; the final exit event carries the process exit code.
Add ?stream=false to buffer instead:
json
{"exit_code":0,"stdout":"<base64>","stderr":"<base64>","duration_ms":142}Pre-exec failures — a bad header, an unknown image, a quota rejection — are 4xx/5xx {"error":…} returned before any streaming starts.
sh
# buffered, with stdin
printf '{"cmd":"wc -c"}\nhello' |
curl -s -H "$H" -H 'Content-Type: application/x-hopbox-exec' \
--data-binary @- 'https://box.hopbox.dev/v1/exec/scratch?stream=false'
# streaming (text/plain convenience, no stdin)
curl -N -H "$H" -H 'Content-Type: text/plain' \
--data 'for i in 1 2 3; do echo $i; sleep 1; done' \
https://box.hopbox.dev/v1/exec/scratchANY /v1/boxes/{box}/proxy/{port}/… — reach a port inside a box
Your box is running a dev server on :3000. This serves it, no port-forward:
sh
curl 'https://box.hopbox.dev/v1/boxes/web/proxy/3000/api/health?key=hbx_…'Everything after /proxy/{port}/ is the path the app sees — the prefix is stripped before the request is forwarded, so …/proxy/3000/assets/app.js arrives as /assets/app.js. The transport is the agent's forward channel (the same one ssh -L uses), so this is a real proxy: any method, request bodies, streamed responses, WebSocket upgrades. A Location header pointing at the app's own origin is rewritten to stay inside the prefix, so redirects don't send a browser chasing localhost.
Framing it in a browser. A page loaded through the proxy issues its own requests for scripts, styles and XHR, and a browser attaches no Authorization header to those. So the first request authenticates with ?key= and gets back a session cookie:
Set-Cookie: hbx_proxy=…; Path=/v1/boxes/<id>/proxy/<port>/; HttpOnly; Secure; SameSite=None; Max-Age=3600That cookie is the only one hopbox sets, and it is deliberately narrow:
Path-scoped to one box and one port — the browser never sends it to/v1/boxes,/v1/mcp, or another box's proxy, and it is refused if replayed there.- Opaque and server-side — a random id in a one-hour table, never your API key.
HttpOnly— the framed app's own JavaScript cannot read it.- Re-checked per request against the box's current owner, so a revoked or transferred box stops answering immediately rather than at expiry.
SameSite=None is required, not casual: a preview is framed from another host, which a browser treats as cross-site, and it withholds a Lax cookie from a cross-site frame's sub-resource requests — the document would load and every script, style and XHR under it would 401. None demands Secure, so over plain HTTP (a local daemon with no TLS proxy) the cookie falls back to Lax, where only same-site framing works.
Your key never enters the box. Authorization, X-Api-Key, the hbx_proxy cookie, and the ?key= that authenticated the request are all stripped before forwarding — a box runs your agents, and an account credential in its access log would sit inside the sandbox it authorizes. A key parameter of your app's own survives untouched.
This is what lilypad's preview pane frames. For a port HTTP can't carry — a database, a raw TCP service — use ssh -L instead.
Preview subdomains
The path form has one limit it cannot fix: an app that emits root-absolute URLs — /assets/app.js, which is what Vite, Next and CRA produce by default — resolves them against the API root, not the prefix, so its scripts and styles 404. Either build the app with a base path (vite --base=…, Next basePath) or give it a host of its own.
With --preview-domain box.hopbox.dev, the same proxy answers at <box>-<port>.box.hopbox.dev:
https://4bef3a6e1e283d60-3000.box.hopbox.dev/?key=hbx_…The app owns the origin root, so /assets/app.js is the app's, and the session cookie becomes host-only for that subdomain — the browser will not send it to box.hopbox.dev or to another box's preview, which is tighter than a Path can be. The box ref is everything before the last hyphen, so a box named my-app gives my-app-3000.….
Operating it takes two things beyond the flag:
A wildcard DNS record —
*.box.hopbox.dev→ the same address as the API,AandAAAAif you serve both.A certificate per preview host. Wildcard certs need a DNS-01 challenge (and a DNS provider credential); on-demand issuance needs neither. In Caddy:
txt{ on_demand_tls { ask http://127.0.0.1:8099/v1/tls-ask } } *.box.hopbox.dev { tls { on_demand } reverse_proxy 127.0.0.1:8099 }GET /v1/tls-ask?domain=<host>answers200only for a preview host naming a box that exists, and403otherwise. Without that gate, anything pointed at the wildcard could make the daemon request certificates until the CA rate-limits you.
GET /v1/mcp — the plane over WebSocket
The full AI-control plane, for clients that can't present an SSH key (a browser app, a third-party integration). Upgrade to a WebSocket, authed by your hbx_ key; the session is per-owner — you see only your own fleet, /wrk, workspaces, secrets, and surfaces. A browser can't set headers on a WebSocket, so the key may go in ?key=:
js
const ws = new WebSocket("wss://box.hopbox.dev/v1/mcp?key=hbx_…");
// then speak MCP JSON-RPC: one request per message.
ws.send(JSON.stringify({ jsonrpc:"2.0", id:1, method:"initialize", params:{} }));The full tool + resource set is the AI-control plane: fleet, box_spawn/delegate (incl. agent:true, which runs on your stored token), box_exec/read/write, lifecycle, wrk.*, ws_list/create/rm, secret_list/set/rm (values never returned or logged), surface_render, and the hopbox://fleet / hopbox://surfaces resources with live push. A wrong or absent key is refused (401) before the upgrade. This is the surface the web console — or any third party — is built on.
Not yet
Rate limiting is a future increment. The API assumes a single-host trust boundary for now.