Skip to content

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 · SCOPE · CREATED · EXPIRES · LAST USED
ssh cli@box.hopbox.dev apikeys rm laptop       # revoke

The 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, revoked or expired key is 401 {"error":"invalid API key"} — an expired key reads exactly like an unknown one.

Scoped keys ​

apikeys create <name> with no flags mints a key carrying your whole account: every workspace, all of /wrk, every secret, and no expiry. That is fine for your own terminal. It is not fine for anything else — and the browser-facing surfaces below (/v1/mcp, …/term, the port proxy) can only take the key in a query string, because a browser cannot set headers on a WebSocket handshake or an iframe's sub-resource requests. So mint the weakest key that works:

sh
ssh cli@box.hopbox.dev apikeys create browser -w ws1 --ttl 24h
  • -w <ws> confines the key to one workspace: the boundary that already scopes boxes, /wrk and secrets.
  • --ttl <dur> gives it a deadline (24h, 30m). It must be positive; omit it for a key that never expires.

What a workspace-scoped key can reach:

Scoped to ws1
GET /v1/boxesonly ws1's boxes — another workspace's box names never appear
A box referencews1 is its default workspace: bare box1 means ws1/box1, never the account's default-workspace box1. ?ws=ws2 (or a ws2/… ref) is 403 naming the scope; another workspace's id is 404, so a refusal never confirms that box exists
POST /v1/exec/{spec}spawns inside ws1 — a spec naming another workspace is refused
/v1/wrk/…ws1's drive with no ?ws=; ?ws=ws2 is 403. The account's default drive is unreachable
…/term, …/proxy/{port}/only ws1's boxes. The preview cookie carries the scope, so trading the key for a cookie cannot widen it
/v1/mcpthe plane is ws1: the fleet, box_*, wrk_* and the secret_* workspace tier (never the account tier). ws_list shows only ws1

What a scoped key cannot do at all, on any surface: create or remove a workspace, point one at a profile, change or build a box profile, answer an ask, or drive an agent session — these are account-wide, so they are refused rather than quietly widened. Canvases are namespaced per scope: a scoped key's surfaces are its own, and it neither reads nor overwrites the ones your unscoped sessions rendered.

A key minted before scopes existed — or minted with neither flag — is unscoped and never expires, exactly as before.

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 + pathBehavior
GET /healthz200 {"ok":true}. No auth.
GET /v1/boxesYour boxes as data: [{id,name,workspace,image,state,age_s,cpus,mem_mb}]. state is the same word cli ls shows; workspace is omitted for your default one, so name + workspace read back as the reference you'd write — box1, or ws1/box1.
DELETE /v1/boxes/{name}Remove a box. 204; 404 {"error":…} if no match, 409 {"error":…} if the reference matches more than one.
POST /v1/boxes/{name}/suspend · /resume200 {"state":…}, or 400 {"error":…} (e.g. the docker backend can't suspend). 404/409 as above.
GET /v1/images · GET /v1/flavorsThe 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}/termWebSocket — 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.
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/mcpWebSocket — 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=ws1); omit it for your default workspace. An invalid name is 400.

Box references ​

{name} and {box} take a box reference — the same grammar ssh cli@host and the MCP plane read:

sh
curl -H "$H" -X DELETE https://box.hopbox.dev/v1/boxes/box1          # your default workspace
curl -H "$H" -X DELETE 'https://box.hopbox.dev/v1/boxes/box1?ws=ws1' # box1 in workspace ws1
curl -H "$H" -X DELETE https://box.hopbox.dev/v1/boxes/a1b2c3        # by id, or any unique prefix

A bare name is your default workspace — box1 never resolves to ws1/box1. The workspace rides ?ws= rather than the path, because a URL path segment cannot carry the /; it is the same key /v1/wrk uses. A reference matching more than one box is 409 {"error":…} naming the candidates, never an arbitrary pick.

The box proxy is the one exception: it forwards the framed app's own query string, so it does not read ?ws= (an app's own parameter must not be able to change which box its requests reach). Address a workspace box there by id.

GET /v1/boxes answers in the same grammar: each box reports the workspace it is in, absent for your default one. Since names are unique only within a workspace, that is both what tells two same-named boxes apart and what a client groups its boxes by — no extra call:

sh
curl -H "$H" https://box.hopbox.dev/v1/boxes | jq -r '.[] |
  if .workspace then "\(.workspace)/\(.name)" else .name end'   # box1, ws1/box1, …

{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/box1/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 /wrk

POST /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/scratch

ANY /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/box1/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=3600

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

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, A and AAAA if 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> answers 200 only for a preview host naming a box that exists, and 403 otherwise. 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 a browser — or any third party — builds on.

Not yet ​

Rate limiting is a future increment. The API assumes a single-host trust boundary for now.

Instant isolated compute — for humans and AIs