REST API

A versioned HTTP API for launching and managing boxes — for IDEs, scripts, and remote hubs

The hub serves a small, versioned REST API under /api/v1 on the same port as the hub UI and relay (http://127.0.0.1:8787 locally). It's for programmatic callers — IDE integrations, scripts, macOS apps, etc. — and is the way to drive AgentBox when the hub runs on a separate host (a control plane), where there's no local CLI to shell out to.

This is not a second surface bolted onto the side. The AgentBox CLI itself drives every box and fleet operation through this same /api/v1 — create, lifecycle, listing, git, approvals, services, checkpoints, prune and custody all go through the hub, against a local hub and a remote control box alike. "Enable a remote hub" is a base-URL swap, not a different code path. (The one operation still handled outside the API is the direct IO planeshell, attach, cp, download, code, open — which talks to the box from your machine; see what still needs your laptop. url and screen go through the API on every provider, except --loopback, which by definition means "this machine".)

Local vs. remote

On your own machine you can also just call the CLI with --json (agentbox list --json, agentbox status <box> --json). The API is what you reach for across a network, or when you want one stable HTTP contract instead of spawning a process per call.

Base URL and versioning

http://127.0.0.1:8787/api/v1

Every path is prefixed with /api/v1. The version is in the path so future breaking changes can ship as /api/v2 without disturbing existing clients.

Auth

Every endpoint except /health, /openapi.json, and /docs requires the hub token as a Bearer credential:

TOKEN=$(cat ~/.agentbox/hub/token)
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8787/api/v1/boxes

The token is the same per-machine secret the hub prints on boot (and stores at ~/.agentbox/hub/token, mode 0600). Requests without a valid token get a JSON 401 — never a redirect, so non-browser clients work cleanly.

On a deployed control box (password-protected hub), /api/v1 accepts either the logged-in browser session or a headless API key as the same Authorization: Bearer credential — so the CLI, the menu-bar app, or a script can call the remote hub without a cookie:

# the key is minted + recorded by `agentbox hub setup`/deploy
KEY=$(grep '^AGENTBOX_HUB_API_KEY=' ~/.agentbox/control-plane/control-plane.env | cut -d= -f2)
curl -H "Authorization: Bearer $KEY" https://<your-control-box>/api/v1/boxes

The key (AGENTBOX_HUB_API_KEY) gates only /api/v1, never the UI pages (those still require a login). A wrong or missing key gets the same JSON 401.

Response shape

Success responses return the resource or collection directly. Errors are always:

{ "error": { "code": "not_found", "message": "box not found: abc123" } }

with an HTTP status that matches the code (400 invalid_request, 401 unauthorized, 404 not_found, 409 conflict, 503 backend_unavailable).

Endpoints

Endpoints are grouped by topic — the same sections you'll see in the sidebar of the interactive reference at GET /docs.

System

Method + pathDescription
GET /healthLiveness + API version (no auth).
GET /openapi.jsonThe OpenAPI 3.1 spec (no auth).
GET /docsInteractive API reference (no auth).

Boxes

Method + pathDescription
GET /boxesList every box (normalized view). Add ?live=1 to refresh each cloud box's state with an authoritative provider SDK probe instead of the fast persisted value — an opt-in, slower path (mirrors GET /providers?freshness=1), populated only on the in-process host topology (the read-only plane serves persisted state). Add ?ref=<id|name|index> to resolve a single box server-side (the same findBox matching the CLI uses: exact id → unique id prefix → name → displayName → sandbox id); pass ?project=<host-path> too for numeric project-index refs. Returns { boxes } with the match set — 0 (none), 1 (unique), or >1 (an ambiguous prefix, for the caller to disambiguate). Ignores live.
GET /boxes/{id}One box.
POST /boxesCreate a box — returns 202 { jobId }. Body: { projectId, agent, provider?, name?, prompt?, fromBranch?, setupWizard? }. agent: "none" just creates the box without starting an agent (prompt ignored). provider defaults to docker; a cloud provider must be configured on the host (see GET /providers). fromBranch bases the box's per-box branch on a chosen ref (branch/tag/SHA) instead of the project's HEAD — validated against the host repo. setupWizard: true seeds the agent's first turn to generate an agentbox.yaml (for projects that have none); inert for agent: "none". When the project has no folder on this machine (the normal case on a control box), the create is routed to the control-plane queue instead — the worker clones the project's repo and overlays its custody seed — and the returned jobId addresses that job through the same GET /jobs/{id} and GET /jobs/{id}/logs. That route needs a repo origin and a cloud provider (docker requires a local checkout).
POST /boxes/{id}/startStart a stopped box (resumes if paused, no-op if already running). Does not restart the agent session — that happens on the next attach.
POST /boxes/{id}/pausePause a running box.
POST /boxes/{id}/resumeResume a paused box.
POST /boxes/{id}/stopStop a box.
POST /boxes/{id}/destroyDestroy a box (container + volumes). On a synthetic job: id it instead dismisses a failed create (clears the queue entry); a create still in progress returns 409. The other lifecycle actions all 409 on a job: id.
POST /boxes/{id}/screenOpen-VNC prep: points the in-box browser at the box's web app so the VNC desktop shows the app instead of a blank X screen (the agentbox screen step). Call it right before opening the viewer; browser-launch failures are logged server-side, not returned. It does not return a URL — get that from GET /boxes/{id}/vnc.
GET /boxes/{id}/vncMint the box's ready-to-open noVNC viewer URL: { url, ttl? }. Docker/hetzner boxes return their stable Portless/OrbStack/loopback URL; cloud boxes (daytona, vercel, e2b) get a freshly signed preview URL on port 6080 — those expire, which is why the box payload's vncUrl is null for them and this must be called at click time. ?ttl=<seconds> (cloud, 1–86400, default 3600), ?loopback=1 (docker). Read-only: it never starts the box, and 409s when the box isn't running or has VNC disabled.
POST /boxes/{id}/renameSet (or clear) a box's cosmetic display label. Body: { displayName } (max 60 chars; an empty string clears it). The container/branch/URLs are untouched. Backs agentbox status <box> --set-name/--clear-name.
GET /boxes/{id}/agentThe box's in-box agent status snapshot (activity, plan/question, session title) from the persisted status store: { agents, claude }. agents is keyed by agent id and is the source; claude repeats that agent's body for older clients and is null on a box running a different agent. Backs agentbox agent state/wait-for/get-plan-question (which read the box's most active agent, or --agent <id>).
GET /boxes/{id}/logsA box service log. ?service=<name> (or ?daemon=1 for the ctl-daemon log). Default returns a JSON { output } tail (?tail=<n>, default 200); ?follow=1 streams it as SSE (open / log* / end) — the hub pipes the in-box agentbox-ctl logs --follow. Backs agentbox logs.

Checkpoints

Checkpoints are durable per-project assets — a docker image or cloud snapshot the box's create warms from. They outlive the box and live in a store on the hub's machine, keyed by the absolute project root (so on a genuinely remote control box a thin laptop can't list them by its own path — a limitation of path-hash-keyed stores). agentbox prune never touches them.

Method + pathDescription
POST /boxes/{id}/checkpointCapture the box state as a project checkpoint (docker commit / cloud snapshot) via provider.checkpoint.*. Optional body { name?, merged?, setDefault?, replace? }. Returns { ok, name, kind, ref, provider, dir?, setDefaultKey? }. Backs agentbox checkpoint create.
GET /checkpointsList a project's checkpoints (?project=<abs root>) or every project's (?global=1): { projects: [{ segment, projectRoot?, label, items }] }. Each item carries isDefault resolved server-side. Backs agentbox checkpoint ls/ls -g.
DELETE /checkpointsDelete a checkpoint (?project=<abs root>&ref=<name>, optional &provider=<p>) from every store that had it, sweeping any dangling default pointer: { ok, removed, clearedKeys, warnedKeys }. Backs agentbox checkpoint rm.

Fleet

Method + pathDescription
POST /prunePrune orphan boxes and resources. Body { provider?, all?, dryRun? }. Without a provider (or docker) it reaps orphan docker records/containers/volumes/box dirs — and, with all, orphan project configs. With a cloud provider it enumerates untracked sandboxes and (when not a dryRun) deletes them and reaps their control-box registrations server-side. Durable checkpoints are always left intact. Backs agentbox prune.

Box git

Method + pathDescription
GET /boxes/{id}/gitLive git summary of the box's worktree: { ok, branch, dirty, ahead, behind }.
POST /boxes/{id}/git/{op}Git op on the box branch (checkout, branch, pull, push, push-host). Body per op: checkout {branch}; branch {name, from?} (create+switch a new agentbox/* branch); pull {remote?, ffOnly?}; push {remote?, force?}; push-host {as?, force?} (land in the host repo only — publishes nothing). Returns { ok, stdout, stderr }.
GET /boxes/{id}/branchesThe box project's branches (local + remote) and current HEAD, for the box git-panel branch picker: { current, branches }.

Box services

Method + pathDescription
GET /boxes/{id}/servicesThe box's agentbox.yaml service/task/port status (source: live | persisted | unavailable).
POST /boxes/{id}/services/restartRestart one service ({ name }) or every service (empty body).

Projects

Method + pathDescription
GET /projectsRegistered projects (create targets). Each carries currentBranch (the default base) and needsSetup (no agentbox.yaml + no default snapshot).
POST /projectsRegister a folder as a project. Body: { path } (absolute).
GET /projects/{id}/branchesThe project's branches (local + remote) and current HEAD, for the create base-branch picker: { current, branches }.
GET /projects/{id}/seedThe project's seed / custody status on a control box — what agentbox hub project push stored (untracked + env/secret tarballs + manifest): { custodyAvailable, seed } as paths, hashes and timestamps only, never seed contents. custodyAvailable: false on a hub that is not a control box.
DELETE /projects/{id}Unregister an empty project (0 boxes). Folder/files on disk are untouched; 409 if it still has boxes.

Providers

| Method + path | Description | | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | GET /providers | Sandbox providers with credential + baked status on this host: { id, label, configured, hasCredentials?, jobId?, reason? }. configured = base image baked (usable for create); hasCredentials = credentials present (docker always); jobId = an in-flight bake, if any. Add ?freshness=1 to also report base-image staleness per provider: baseStatus (fresh | stale | unprepared | unknown) + baseStaleReason when stale — stale means the baked snapshot is out of date and agentbox prepare --provider <id> should be re-run. Docker reports real freshness too (unprepared = the base image hasn't been built yet, stale = the next create will rebuild it) while staying configured: true — its base self-heals at create time, so freshness is a heads-up for UIs to announce the bake, never a gate. Freshness loads provider code and hashes the runtime build context (memoized ~60s for cloud providers), so it's kept off the default fast path; it's populated only on the in-process host topology (omitted on the hosted plane). | | POST /providers/{id}/credentials | Set a provider's credentials (validated against the cloud, then saved to ~/.agentbox/secrets.env). Body is provider-specific string fields: { apiKey } (e2b/daytona), { token } (hetzner), { token, teamId?, projectId? } (vercel). Never echoes secret values. Host-topology only (503 on the hosted plane). | | POST /providers/{id}/prepare | Bake a provider's base image — returns 202 { jobId }. Optional body { force?, agentSettings? } (agentSettings is { "<agent>": { "<key>": string | boolean } }— see agent settings). Progress streams overGET /jobs/{id}/logs(same as create). Prechecks the host (docker daemon; hetznerssh/scp; credentials present) and reuses an in-flight bake for the same provider. Bakes are serialized per provider, so a provider bakes one image at a time while different providers bake in parallel (each remote-docker host is its own lane too). |

Agents

Method + pathDescription
GET /agentsThe coding agents this hub can start: { id, label, installed? }. Driven by the agent registry, so an agent added with agentbox agent add is listed too — read this instead of hardcoding agent names. installed reports whether this machine holds the agent's config directory (e.g. ~/.claude) or an AgentBox-saved login (~/.agentbox/<id>-credentials.json); it tells a picker which agents to offer first and is not a gate — an agent installs on demand inside the box, so creating with an uninstalled one works. Omitted when the hub has no host to answer for (the hosted plane), which means unknown, not false.

Approvals

Method + pathDescription
GET /approvalsPending host-action approvals.
POST /approvals/{id}/answerAnswer an approval. Body: { answer: "y" | "n", cancelled?: boolean }. cancelled: true marks a dismissal distinctly from a plain deny in the audit trail (the agentbox agent approve --cancel capability); both leave the action unapproved.
GET /boxes/{id}/streamPayload-carrying SSE for one box (the attach footer's channel). Emits open (first frame of every connect, followed by a backlog flush of everything still live), prompt-ask (the full pending-approval payload), prompt-resolved ({ id }), notice-set/notice-clear, box-status (the in-box daemon's snapshot: agent activity, session titles, service/task states), and ping. box-status is the only status source for a box this hub owns but the client does not. Distinct from /api/events, which carries refetch signals only (data: {}).

Jobs

Method + pathDescription
GET /jobsThe unified job listing — the local file queue's create jobs merged with, on a control box, the control-plane create queue: { jobs: [{ id, status, boxId?, error?, provider?, name?, agent?, createdAt? }] }. Backs agentbox queue list and agentbox hub jobs.
GET /jobs/{id}Create-job status (queued/running/done/failed), plus error/provider/name/agent/login when set.
GET /jobs/{id}/logsStream the build log (SSE).
POST /jobs/{id}/login-codeDeliver a pasted Claude OAuth approval code ({ code }) to a create job awaiting a re-login — the one interactive create affordance that survives. The worker consumes it and completes the in-box login.

Custody

Custody holds agent credentials, .env/secret files, and per-box SSH private keys — the highest-value target in the whole API. It follows a two-tier contract: list / PUT / DELETE authorize with the hub API key and their responses are metadata only (paths, hashes, sizes, mtimes — never a value); the byte-read GET /custody/{path} is the only route that returns a value, and it is elevated — on a control box it additionally requires the admin token, so the widely-distributed API key alone can never read a secret.

Method + pathDescription
GET /custodyMetadata of what the hub holds so a box created from either side is usable from both — agent credentials, project seeds, provider bake records, and per-box SSH keys: { enabled, entries: [{ path, size, sha256, mode, updatedAt }] }. Value bytes are never returned (same contract as agentbox hub custody list). Optional ?prefix= scopes to a custody scope (agents | projects | prepared | boxes) or a scope/subject (e.g. boxes/box-abc); a malformed prefix is 400. Drives the web UI's Custody page.
PUT /custody/{path}Store bytes at a custody path (body { data: <base64> }). Returns metadata only — { path, size, sha256, mode, updatedAt, changed }, never the stored value; changed: false when the bytes were already there (content-addressed). Backs agentbox hub credentials/secrets/project push.
DELETE /custody/{path}Remove one custody entry (204, or 404 if absent). Backs agentbox hub custody rm.
GET /custody/{path}Read a stored blob's bytes ({ …metadata, data: <base64> }). Elevated: on a control box this additionally requires the admin token in X-Agentbox-Admin-Token; the hub API key alone can list and write but never read a value — a byte-read with only the API key is 401. (A localhost hub needs no admin token — its hub token is a machine-local secret — but the byte-read is loopback-only there: the localhost hub binds 0.0.0.0 so containers can reach it, so a non-loopback byte-read is refused even with a valid token, because custody bytes must not leave the machine over the network.) Backs agentbox hub credentials pull / custody pull and per-box SSH-key adoption.
PUT /custody/blob/{path}Stream bytes into a custody path (application/octet-stream). The advanced counterpart of PUT /custody/{path}: the JSON route stays the easy, general-purpose API for small values, while this one carries payloads where base64-in-JSON would cost several times the payload in peak memory on both ends — chiefly a project's carry: material, which can run to box.cpMaxBytes (100 MiB). Capped by AGENTBOX_CUSTODY_MAX_BLOB_BYTES and enforced mid-upload, so an over-cap body is cut off rather than landed. Returns metadata only, content-addressed like the JSON route.
GET /custody/blob/{path}Stream a stored blob's bytes back (application/octet-stream, digest in X-Agentbox-Sha256). Same elevated gate as the JSON byte-read — streaming changes the transport, never the trust.

System

Method + pathDescription
GET /system"What is running here, and do I need to re-bake?": { hub, build, deploy, providers, hostCarried, boxImage } — the hub version/channel/build source, the deploy record (when this machine is an exposed/deployed control box), each base provider's baked fingerprint + freshness (baseStatus stale = re-bake, with baseStaleReason and a bakeDiff naming the files that changed), the agent configs/skills this machine carries into a box, and the box-image registry + exact pull tag. Freshness is populated only on the in-process host topology (like GET /providers?freshness=1). Drives the web UI's System & Build page.

The box view is provider-agnostic: { id, projectId, repo, branch, task, agent, status, createdAt, lastActivity, host, commits, filesTouched, error, displayName, webUrl, vncUrl }, where status is one of running | paused | stopped | creating | error. Boxes still being created (and failed creates) appear as synthetic creating/error entries with a job: id until the real box lands. A failed create can be cleared with POST /boxes/{id}/destroy (dismiss). On the local host topology each box also carries raw host-side fields for native clients (the tray app): state (the raw provider state — absent on synthetic entries, which is how a client tells a failed create from a running box whose agent errored), name, provider, projectRoot, projectIndex, vncEnabled, gitWorktrees, shellCount, and agentStatus — every agent reporting in the box, keyed by agent id, each { state, sessionTitle? } where state is one of working | idle | waiting | end-plan | question | compacting | error | unknown. The older named fields (claudeSessionTitle, codexSessionTitle, opencodeSessionTitle, claudeActivity, codexActivity, opencodeActivity) are still sent, but they are a projection of agentStatus over the three built-in agents — read agentStatus if you want to see any other agent. vncEnabled — not vncUrl — is the "this box has a desktop" bit: vncUrl is only ever set for boxes with a stable URL (docker, hetzner), and is null on daytona/vercel/e2b because their signed preview URLs expire. Gate the affordance on vncEnabled and fetch the URL from GET /boxes/{id}/vnc when the user clicks. Cloud boxes also carry the fields needed to reconstruct or adopt them from the payload alone (all undefined for docker/synthetic rows): sandboxId, originUrl, publicHost, image, webPort, previewUrls, lastAgent, and topology.

Creating a box

Creation is asynchronous — it enqueues a build job and returns immediately:

# Register the folder once (skip if it's already a project):
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"path":"/abs/path/to/repo"}' \
  http://127.0.0.1:8787/api/v1/projects

# Create a box for it (grab the projectId from GET /projects):
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"projectId":"<id>","agent":"claude","name":"feature-x"}' \
  http://127.0.0.1:8787/api/v1/boxes
# -> 202 { "jobId": "..." }

The body takes either a projectId (a registered project on the hub's machine — built from its local checkout) or a repoUrl (an origin the hub clones VPS-side when there is no local checkout, e.g. on a control box). Same route, same 202 { jobId } — the hub picks the build path internally. Optional fields mirror the CLI's agentbox create flags: agent, provider, name, prompt, agentArgs, fromBranch, and an opts object for box-shaping knobs (image, snapshot, size, location, envFiles, carry, …).

A box being built shows up in GET /boxes with status: "creating" right away, then flips to running once the container is up. GET /jobs lists every create job (the unified queue view behind agentbox queue list / hub jobs); GET /jobs/{id} is one job's status (queued/running/done/failed, plus boxId and, on failure, error).

Streaming logs (SSE)

GET /jobs/{id}/logs is a Server-Sent Events stream: an open event, then a log event per line, then a terminal end event with the final status.

curl -N -H "Authorization: Bearer $TOKEN" \
  http://127.0.0.1:8787/api/v1/jobs/<jobId>/logs
event: open
data: {"id":"<jobId>"}

event: log
data: "... building image ..."

event: end
data: {"status":"done"}

Installing providers & baking images

Before a cloud provider can host boxes it needs two things on the host: its credentials and its baked base image. Both are drivable over the API (the hub settings UI uses exactly these calls), so you never have to drop to a terminal:

# 1. Save credentials (validated against the cloud, then written to secrets.env):
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"apiKey":"e2b_..."}' \
  http://127.0.0.1:8787/api/v1/providers/e2b/credentials
# -> 200 { "ok": true }   (a rejected token -> 400 with the reason)

# 2. Bake the base image (async — stream the build like a create job):
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{}' http://127.0.0.1:8787/api/v1/providers/e2b/prepare
# -> 202 { "jobId": "..." }
curl -N -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8787/api/v1/jobs/<jobId>/logs

Once the bake finishes, GET /providers reports the provider as configured: true and it becomes a valid provider for POST /boxes. Docker needs no credentials; its base self-heals, so a bake there is optional (a force rebuild).

Host capability

These two endpoints run on the host that owns Docker/SSH and writes ~/.agentbox — the local or full-host hub. On the hosted (serverless) plane they return 503.

Exploring the API

GET /api/v1/docs renders the full spec (an interactive reference), and GET /api/v1/openapi.json returns the raw OpenAPI 3.1 document you can feed to a client generator or an IDE's HTTP client.

On this page