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 plane — shell, 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
--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/v1Every 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/boxesThe 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/boxesThe 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,
500 internal).
Two more codes also answer 409, and both carry a details object naming the
machine the call has to run on: wrong_host (a manager op sent to a hub that
is not the machine running the session) and manager_unreachable (a message
the answering hub cannot type). Branch on error.code, not on the status:
{
"error": {
"code": "wrong_host",
"message": "manager 4f2a…c1 runs on marcos-mbp, not on control-box; resume it there",
"details": { "host": "marcos-mbp" }
}
}When details.host is your own hostname, retry the identical call against the
hub on your machine (agentbox hub target --json --local). A refusal about a
workspace rather than one manager — managers/start, managers/sessions —
also carries details.hosts, every machine with a checkout of it, because a
workspace mapped from several machines has no single right answer; retry when
your hostname is in that list.
Endpoints
Endpoints are grouped by topic — the same sections you'll see in the sidebar of
the interactive reference at GET /docs.
System
| Method + path | Description |
|---|---|
GET /health | Liveness + API version (no auth). |
GET /openapi.json | The OpenAPI 3.1 spec (no auth). |
GET /docs | Interactive API reference (no auth). |
Boxes
| Method + path | Description |
|---|---|
GET /boxes | List 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 /boxes | Create 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". opts carries the box-shaping knobs the caller already resolved (image, snapshot, limits, size/location, persistent, …); absent keys fall back to the hub's own config. On the control-plane route (below) only the portable subset is forwarded — providerOptions carries the per-provider knobs (timeoutMs, networkPolicy, sandboxClass, DigitalOcean's project) as one object, and its extraInboundCidrs / remoteHost are the hub's to set and refused from a client, since one of them opens a firewall. A key this hub does not understand is reported as a warning on the job log rather than silently dropped, and opts.gitPushMode: "direct" is refused: it copies a git credential into a box on a machine the caller does not own. A service agent (one whose GET /agents row reports surface: "service", e.g. openclaw) creates a persistent box by default — it hosts a daemon, so an autopause would be an outage — and a persistent create on e2b/vercel is refused with conflict rather than silently downgraded; pass opts.persistent: false for an expendable box. A service-agent box has no session to attach to: the build ends at a running daemon. 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}/start | Start 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}/pause | Pause a running box. |
POST /boxes/{id}/resume | Resume a paused box. |
POST /boxes/{id}/stop | Stop a box. |
POST /boxes/{id}/destroy | Destroy 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}/screen | Open-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}/vnc | Mint 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. |
GET /boxes/{id}/web | Resolve a box's web URL at click time: { url, signInUrl, signInPending }. Prefer this over the box payload's webUrl whenever you are about to OPEN the URL — that field is a recorded value, and where the URL is an SSH forward (hetzner/DO) the live port differs once the session has been re-established, the same class of reason vncUrl is null for signed-URL clouds. signInUrl is set for a service agent (openclaw) whose UI reads an auth token from the URL fragment: <url>/#token=…, generated inside the box. It is null when the agent declares no such token or has not written one yet, and signInPending tells those apart: true means the box's daemon HAS a token and could not give one because it is still starting, so url opens on a sign-in prompt or a dead port — wait and ask again rather than opening it. false means url is the complete answer. 409s when the box is not running. The hub's own UI links at the page route /boxes/{id}/web, which 302s to the same answer — an <a href> navigates synchronously, while a fetch then window.open loses user activation and is popup-blocked. |
POST /boxes/{id}/rename | Set (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}/agent | The 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>). |
POST /boxes/{id}/upload | Push the host workspace into the live box — the mirror of agentbox download. A git workspace merges the host branch into the box's and overlays the host's uncommitted/untracked changes; a non-git one gets a plain file overlay. The box wins every conflict: nothing in the box is overwritten or reset, and the skipped host paths come back in conflicts. Optional body { includeNodeModules? }. Returns { mode: "git"|"files", copied, conflicts }. Starts the box if it is paused. Needs the in-process host backend and reads the workspace off the hub's own disk, so a client on another machine cannot push its files this way — agentbox upload refuses against a remote control box rather than uploading the hub's copy of the project. Backs agentbox upload. |
POST /boxes/{id}/clone | New box from this box's workspace files and its agentbox.yaml, with a fresh agent identity — the agent's config volume and credential are deliberately not copied, so it onboards from scratch (there is no --with-state). Exports the workspace (gitignore/exclude aware, agent state dropped) into a new host project dir, then enqueues a normal create seeded from it. Optional body { name?, provider?, into?, includeNodeModules?, persistent? }; into and the default ~/.agentbox/clones/<name> are paths on the hub's machine, and into must be absolute (a working directory is client state that does not travel over an API — a relative path is rejected with invalid_request; the CLI resolves it against your cwd before sending). The create is enqueued in the ungated foreground lane, since the caller is blocked on the job stream. Omit persistent to inherit the source box's — a clone of an always-on service box is always-on too; true on e2b/vercel is refused with conflict. Returns { jobId, name, workspace, provider, files, persistent? } — stream the create through GET /jobs/{jobId}/logs. .git is not exported: the clone is a template. Backs agentbox clone. |
GET /boxes/{id}/logs | A 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 + path | Description |
|---|---|
POST /boxes/{id}/checkpoint | Capture 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. |
POST /boxes/{id}/backup | Capture a bot: the workspace plus, when the box's agent declares one (the box payload's supportsBackup), its whole state dir — identity included: gateway token, channel pairings, history. Writes <project>/.agentbox/bots/<bot>/<stamp>/ on the hub's machine; live databases go through SQLite's online-backup API rather than a byte copy. Unlike a checkpoint this is provider-neutral files, so a bot captured on e2b restores onto hetzner. The state half is best-effort — a box whose agent can't be reached still leaves a usable workspace bundle, and state: false in the answer (and in the bundle's manifest) says so before a restore starts. Optional body { name?, keep?, agent?, includeNodeModules? } (keep defaults to 3; older bundles are pruned). Returns { ok, bot, stamp, dir, agent?, state, databases?, files, pruned, wroteGitignore }. The state/ dir holds a live credential, so it is written 0700 and .agentbox/ is added to the project's .gitignore. Backs agentbox download --backup; read back by POST /projects/{id}/restore. |
GET /checkpoints | List 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 /checkpoints | Delete 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 + path | Description |
|---|---|
POST /prune | Prune 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 + path | Description |
|---|---|
GET /boxes/{id}/git | Live 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}/branches | The box project's branches (local + remote) and current HEAD, for the box git-panel branch picker: { current, branches }. |
Box services
| Method + path | Description |
|---|---|
GET /boxes/{id}/services | The box's agentbox.yaml service/task/port status (source: live | persisted | unavailable). |
POST /boxes/{id}/services/restart | Restart one service ({ name }) or every service (empty body). |
Projects
| Method + path | Description |
|---|---|
GET /projects | Registered projects (create targets). Each carries currentBranch (the default base), needsSetup (no agentbox.yaml + no default snapshot), and lastProvider / lastAgent / lastUsedAt — what that project's last create picked, for a create picker to pre-select. Those three are advisory: lastProvider may be a docker:<alias> spec, and either may name a provider/agent this host no longer offers, so clamp them against GET /providers and GET /agents (falling back to docker / the first agent). |
POST /projects | Register a folder as a project, or create one. Exactly one body: { path } (absolute, existing folder) registers it; { parent, name, git? } creates <parent>/<name> — empty by default, or with git: true a repo on main with a .gitignore (.agentbox/) and an initial commit — and registers it. A create is refused when the target exists or when parent sits inside another project (its agentbox.yaml would claim the new folder). Both answer { ok, id, path }. |
GET /projects/{id}/branches | The project's branches (local + remote) and current HEAD, for the create base-branch picker: { current, branches }. |
GET /projects/{id}/bots | Every bot this project holds a backup of, alphabetically, each with its backups newest first and its manifest folded in: { bots: [{ bot, latest?, backups: [{ stamp, agent?, state, boxName?, provider? }] }] }. The source for a restore picker. state: false on a backup means it captured only a workspace, so restoring it gives a working box with a fresh identity — surface that rather than offering it as "restore this bot". |
POST /projects/{id}/restore | Bring a backed-up bot back as a new box, identity included — the inverse of POST /boxes/{id}/backup, and the opposite of clone (a clone starts a second bot with a fresh identity; a restore puts the original one back: same token, same pairings, same history, on whichever provider you point it at). Project-scoped because a bundle outlives the box it came from. Body { bot, stamp?, name?, provider?, into?, force?, persistent? } — stamp defaults to latest, into must be absolute and defaults to <project>/.agentbox/bots/<bot>/workspace, persistent defaults to true. Stages the workspace half, registers it as a project and enqueues a normal create in the ungated foreground lane; the state half is pushed by the worker once the box's service has come up on its own. Refuses, before writing anything: the source box still running (two live gateways cannot share one identity), a destination another box already runs on, and a bundle that captured no state. force overrides the first two. Returns { jobId, name, workspace, provider, bot, stamp, agent, files, persistent? } — stream it through GET /jobs/{jobId}/logs. Backs agentbox <service-agent> --restore <bot>. |
GET /projects/{id}/seed | The 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. |
Workspaces
A workspace groups one or more projects, owning a task list and its manager sessions. It is
machine-independent: projects are repos, and hosts maps every machine that has a checkout to
its folders ({ "<hostname>": { root, projectRoots, seenAt } }), so a hub that never cloned a repo —
a control box — still joins the boxes built from it to the right workspace. root on the payload is
the reading hub's own folder and is absent when it has none. You rarely register one by hand:
detecting a manager session in a folder no workspace contains creates one there. Host state: a
hosted hub holds none, so its listing is empty rather than an error.
The client scans, the hub records: the folders are on the caller's machine and the hub never stats them.
| Method + path | Description |
|---|---|
GET /workspaces | Registered workspaces: { workspaces: [{ id, name, projects, hosts, root?, projectIds, taskCounts, managers }] }. A project is { id, name, repoUrl? }, keyed by its normalised origin (or by <host>:<folder> with no remote). projectIds carries those ids plus the path hash of each folder this hub has, which is what a box record and GET /projects key by. managers is { running, total }. |
POST /workspaces | Register { host, root, projects: [{ path, name?, repoUrl? }], name?, id? } — the scan the caller ran on its own machine (the folder itself and its immediate subfolders with a .git or agentbox.yaml, each with its origin). Idempotent, matched in order by id, then by (host, root), then by any shared repo in a record that has no folder on host yet — that last case merges another machine's checkout into the existing record instead of forking a rival workspace, while a second working copy on the same machine stays its own workspace. Project folders on the hub's own machine are registered in GET /projects too; another machine's are not the hub's to register. agentbox workspace rescan re-posts with id. |
GET /workspaces/{id} | One workspace. |
POST /workspaces/{id}/rename | { name }. |
DELETE /workspaces/{id} | Unregister it and drop its tasks and managers. The folder, its projects and their boxes are untouched. 409 while any of its managers is running, unless ?force=1. |
GET /projects gains workspaceId on every project a workspace lists, and GET /boxes gains
tasks ({ total, done, current }) on every box tasks are assigned to and managerId on every box a
manager session created. All are optional: absent means "no data" — never "zero" — and all are
absent on a hosted hub. A box created from the web UI or the tray has no managerId.
GET /boxes also gains pr ({ repo, number, url?, state }, state one of open, ready, merged,
closed) on every box with a known pull request: matched in its workspace timeline by box
id, else by the box's branch against the PR's head branch. When several match, an open PR wins over a
merged or closed one, then the newest. state is the last GitHub sync's when this hub has one, else
the log's.
Tasks
Units of work, ordered by priority (list order is the priority) and assigned to boxes. Many tasks to one box is the normal case: that is how a manager keeps tasks touching the same files on one branch. Assignments are healed on every read — a task assigned at create time moves from the job id to the box id once the worker records it, and a task whose box is gone returns to the backlog with its status untouched.
| Method + path | Description |
|---|---|
GET /tasks | Tasks across every workspace. Filter with ?workspaceId=, ?projectId=, ?boxId=, ?status=, ?managerId= — "what is this box working on" without knowing which workspace owns it. |
GET /workspaces/{id}/tasks | One workspace's tasks, same filters minus workspaceId. |
POST /workspaces/{id}/tasks | Add one: { title, description?, projectId?, dependsOn?, createdBy?, externalRef?, boxId? | boxJobId?, managerId? }. externalRef back-references a tracker (one ticket → several tasks). With a box and no managerId, the task joins the box's manager when that manager belongs to this workspace. A managerId of another workspace (or an unknown one) is 400. |
GET /workspaces/{id}/tasks/{taskId} | One task. |
POST /workspaces/{id}/tasks/{taskId} | Update it. projectId: null clears the project scope and managerId: null the manager; an omitted field is left alone. A managerId of another workspace is 400. (This API has no PATCH.) |
DELETE /workspaces/{id}/tasks/{taskId} | Delete it, and drop it from every other task's dependsOn. |
POST /workspaces/{id}/tasks/{taskId}/done | Mark it finished. |
POST /workspaces/{id}/tasks/{taskId}/assign | Assign one task: { boxId } or { boxJobId }, exactly one. A task with no manager joins the manager that created that box. |
POST /workspaces/{id}/tasks/{taskId}/unassign | Back to the backlog. Finished work stays finished. |
POST /workspaces/{id}/tasks/assign | Assign several at once: { ids, boxId | boxJobId }. The bulk form a manager uses when it groups tasks into a box. |
POST /workspaces/{id}/tasks/reorder | { ids } — an exact permutation of the workspace's tasks. A partial list is refused rather than silently renumbering the rest. |
Managers
A manager is a host agent session that creates and watches boxes — many per workspace, of three
kinds. external is a claude or codex session in someone's own terminal: the CLI registers it the
first time it runs inside it (POST /managers/detect), and the hub only observes it. pty is one a
hub started on an AgentBox pty host on its own machine — one process holding the agent's terminal,
with no multiplexer in between, so every client gets the agent's raw bytes and several can attach at
once. tmux is the same thing on the older carrier, used where the optional terminal prebuild is
missing. A running pty manager carries ptyAttach (an absolute argv, its socket and the protocol
version), so an embedding client needs nothing on its own PATH.
A manager runs on one machine; its record lives with the workspace. With a control box configured
those are different machines: the session, its tmux server and its transcript are on your PC, while
the record, its notes and its timeline are on the box. host is the machine it runs on and
hostIsHub says whether the hub that answered IS that machine. Every process op — start, resume,
attach, stop, sessions, message — only works there; anywhere else it answers 409 with code
wrong_host and details.host, and the client retries against the hub on that machine (its own
local hub, when details.host is its own hostname — agentbox hub target --json --local points at
it). The PC hub keeps that record current by reporting to POST /managers/{id}/heartbeat, and tells
it about a box it built for that manager with POST /managers/{id}/attach-box. Registering,
reporting and attaching a box are the only manager writes a hub accepts from another host.
status is derived, never stored: on the machine that runs it, whether the session host answers for
a pty manager, the tmux session for a tmux one, and for an external one its pid, else "seen in
the last 30 minutes". A claude session that
Claude Code hosts in its background daemon (claude --bg, listed by claude agents) is running
while the daemon runs it, whatever is attached. On any other hub the row comes from the last
heartbeat, and falls back to the last-seen window once three of them are missed. An external session
that has ended can be resumed, after which it is hub-run.
Claude's daemon hands the environment of the client that started it to every session it hosts, so a
detect's managerId ($AGENTBOX_MANAGER) is believed only for a manager in the same folder that has
no session yet or is hub-run and running. For a pty manager it is believed only when the detect
also carries runId ($AGENTBOX_MANAGER_RUN), which its session host minted — proof the leaked
variable cannot supply. A detect that names the AgentBox tmux session it runs in
(tmuxSession) records the manager as run from it, when that session exists on the hub's machine
and started in the detect's folder.
A manager: { id, workspaceId, workspaceName, agent, kind, host, hostIsHub, status, resumable, resumeBlockedBy?, cwd, sessionId?, title?, pid?, pidStartedAt?, tmuxSession?, tmuxPane?, attachCommand?, boxIds, boxJobIds, taskCounts, createdAt, lastSeenAt, startedAt?, stoppedAt?, lastExit?, background?, terminalSession? }. attachCommand is present for a running hub-run manager, and for a
background session while the hub's attach session for it is up. background ({ id, status?, state?, name? }) marks a
claude session running detached in Claude's daemon — no AgentBox tmux session starts in its folder and no claude attach
client shows it — which POST /managers/{id}/attach opens. terminalSession names the one unclaimed AgentBox tmux
session that started in a running external manager's folder (for example one from before managers were detected): a
client may offer to open it, but nothing ties it to the manager for sure. resumable says whether a resume
would be accepted at all (a client disables Resume on false), and resumeBlockedBy says why not —
running, no-session or unsupported-agent, absent when resumable is true. It says nothing about
WHERE: that is host/hostIsHub. title is the session's first user turn when the machine running
it can read the agent's store.
| Method + path | Description |
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- |
| GET /managers | Every manager, running first then most recently seen: { managers }. Filter with ?workspaceId= and ?status=running\|stopped. |
| POST /managers/detect | { agent, sessionId, cwd, pid?, host?, managerId?, tmuxPane?, tmuxSession?, boxId? \| boxJobId?, projects?, home? } — register the session a CLI call came from, matched by (agent, sessionId) so repeating it refreshes one record. managerId (the caller's $AGENTBOX_MANAGER) joins it to the hub-run manager it runs inside. When no workspace on host contains cwd, one is created there from the caller's projects scan (same shape as POST /workspaces) — except at /, the caller's home, or any folder above it, which answer 400 (register the project folder with POST /workspaces instead). The folder is never stat'd: it is on the caller's machine. boxId/boxJobId attaches a box the session just made. Returns { manager, workspace }, 201 when either was created. |
| GET /managers/{id} | One manager. |
| DELETE /managers/{id} | Forget it. Its boxes and tasks are untouched. 409 while it runs, unless ?force=1 (the process, if any, is left alone). |
| POST /managers/{id}/stop | End a hub-run manager's session (its pty host runs the kill ladder, or its tmux session is killed); idempotent, and the record is kept for a resume. An external manager is your own process, which the hub never signals: 409 while it runs. For a claude manager whose session runs in Claude's background daemon only the hub's attach session closes: the session keeps running, and the answer adds notice saying so. |
| POST /managers/{id}/pin | { pinned } — keep a hub-run session alive with nothing attached to it, or stop doing so. Without a pin, a session a client holds a lease on is stopped once that client stays away past the grace window (manager.leaseGraceSeconds), so quitting the app that opened it ends its terminals while a relaunch inside the window keeps them. Written to the record and pushed to the live session host. 409 for an external manager: the hub never keeps your own terminal process alive. |
| POST /managers/{id}/attach | For a manager with background (a detached Claude Code background session): start, or reuse, the tmux session agentbox-manager-<id> running claude attach <background.id> in its folder, and answer the manager with attachCommand. Its kind and session are unchanged, and it stays running after that tmux session ends. 409 without a running background session, or when an AgentBox tmux session in its folder or a claude attach client may already show it; 503 without tmux. |
| POST /managers/{id}/resume | Reopen the session in this hub's tmux (claude --resume <id> / codex resume <id>, in the manager's cwd). 409 while the session still runs anywhere, for a manager with no session id, and for an agent whose sessions cannot be resumed; 409 wrong_host when host is not this machine (its transcript is not here); 503 without tmux. |
| POST /managers/{id}/heartbeat | { status, sessionId?, title?, turn?, prompt?, lastExit?, background?, terminalSession?, tmuxSession? } — what the machine running the manager sees right now, sent every 30 s and after each start, resume, stop or attach. Drives that manager's row here until three intervals pass with no heartbeat. 409 wrong_host for a record whose host is this hub's own: there the process is readable, and a report nothing can check must not override it. | |
| POST /managers/{id}/attach-box | { boxId } or { boxJobId } — the hub that BUILT a box says which manager it belongs to. Usually that hub is this one and nothing travels; with a control box configured and hub.mode=local the box is built on the user's own machine while the record lives here. Appends one id to the manager's list and can neither move nor remove anything, which is why — unlike every other patch — it is accepted from another host. |
| POST /managers/{id}/notes | { text, kind? } (note | replan | plan) — record why the manager did something on the workspace timeline, stamped with its current turn and that turn's prompt when its transcript is on the hub's disk. 201 with the event. |
| POST /managers/{id}/message | { text, prNumber?, repo? } — type a message into the manager's session and submit it: a running hub-run manager's tmux session, a running external manager's tmux pane (recorded at detect, on the hub's machine only), or a stopped manager resumed in the hub's tmux with the text as its prompt. Answers { delivered: session \| pane \| resumed, manager, event }. A running external manager outside tmux is 409 with code manager_unreachable: offer the text to paste instead. So is a manager on another machine, with details.host naming it — retry there. prNumber ties the message to a PR, so its merge shows approvedByYou; add repo (owner/name) when the workspace spans several repos, since a bare number is matched only when one repo in the log has it. |
| GET /workspaces/{id}/managers | The workspace's managers: { managers }. |
| POST /workspaces/{id}/managers/register | { id?, agent, kind: "tmux", host, cwd, tmuxSession, sessionId?, argv? } — record a manager session another machine just opened, where the workspace lives. With an id that manager moves onto the new session (manager.resumed); without one a manager is minted (manager.started). This and the heartbeat are the only manager writes accepted from another host. argv is recorded, never executed here. |
| POST /workspaces/{id}/managers/start | { agent, sessionId?, restart? } — start a manager in the workspace folder, in a tmux session this hub owns. 409 wrong_host when the workspace has no folder on this machine. agent is one this hub knows (GET /agents, plugin agents included) that is installed here and has a session to attach to — a service agent such as openclaw is a daemon, so it is refused. There is deliberately no free-form argv: this runs on the hub host, not in a box, and sessionId is constrained to an id shape for the same reason. sessionId resumes a session (claude and codex only); one a manager already holds resumes that manager (409 while it runs, unless restart and it is hub-run). 503 without tmux. |
| GET /workspaces/{id}/managers/sessions | Sessions in that folder a manager could resume, read from the agent's own store (?agent=, default claude): { agent, supported, sessions: [{ id, title, updatedAt }] }. supported: false is not an error — that agent's session format is not one the hub can resume. 409 wrong_host when the workspace has no folder on this machine: the sessions are files in it. |
Timeline
What happened in a workspace, from an append-only log every mutation writes to — task, manager, box, push and pull-request events — plus the rows that are true right now.
| Endpoint | Description |
|---|---|
GET /workspaces/{id}/timeline | { items, live, summary?, github }, items newest first. ?before=<iso>&limit= (default 100, max 500) pages back; ?since=<iso> adds summary: { since, merged, additions, deletions, tasksDone, awaiting } and does not filter the items. ?sync=0 skips starting the background GitHub PR sync (github reports the last one). ?managerId= narrows items, live and summary to one manager session: the rows it stamped, plus the rows on a box it owns (most box, push and PR rows carry no managerId of their own). An unknown id reads as an empty timeline, not an error. |
POST /workspaces/{id}/timeline/events | Record one event from a hub that does not hold the store. Body is an event without id (minted here); actor must be box, hub or manager — a human action is made through this hub's own routes, which stamp it from the session header. 201 { event } on append, 200 { deduped: true } when the event's key was already in the log, 404 for a workspace this hub does not have. at is honoured, so a late forward is ordered by when the thing happened. |
An event is { id, at, type, actor, managerId?, turn?, prompt?, boxId?, boxName?, agent?, branch?, base?, projectId?, taskIds?, task?, pr?, text?, noteKind?, boxRunning?, additions?, deletions?, key? }. actor is human,
manager, box, hub or github. type is one of task.created | status | assigned | unassigned | removed, manager.joined | started | resumed | stopped | note | message, box.created | ready | failed | started | stopped | destroyed | branch, git.push, and pr.opened | ready | merged | closed.
base on box.created is the branch the box forked from: the create's fromBranch, else the branch the
project's checkout was on (absent on a detached HEAD). box.branch is written when a
git/checkout or git/branch moves the box onto another branch, with branch the branch it is now on
and base the one it left; a checkout with args (paths) or one that leaves HEAD detached writes
none. box.ready carries base only when the create named a fromBranch. A
git.push carries additions/deletions from the old tip to the new one, or from the merge base
with the default branch for a first push. They are read from the host repo when the hub holds that
folder, and measured inside the box when it does not — a control box has no checkout of anything.
taskIds is captured when the event is written, and pr is { repo, number, title, url, base, head, additions?, deletions?, checks?, mergeState?, autoMerge?, mergedBy? }.
An item is an event, or type: plan (count, taskIds, turn, prompt) when a manager created 3 or
more tasks in one turn within 10 minutes. A pr.merged preceded by a message about that PR carries
approvedByYou. live rows are task.in_progress — a box working a task, with filesChanged,
additions and deletions of its uncommitted diff while it runs (left off a read when the box takes over
3 s to answer) — and pr.ready, with awaiting and, once a message about it was sent, approved.
After a hub restart, a ready PR stays out of live and summary.awaiting until a GitHub sync of the
workspace confirms it.
Items and live rows that name a branch carry branchUrl, the branch on the web
(https://github.com/acme/storefront-web/tree/feat/checkout-copy, each segment URL-encoded). A PR
row's branch is its pr.head. The repo comes from pr.url, else a repo the GitHub sync resolved, else
the row's project or box as the last sync cached it; without one of those the field is absent. A read
never runs gh for it, so a row can gain the link on the read after the first sync.
Lanes. Every item and live row carries lane: { id, kind, from?, into?, branch?, open? }, for
drawing the timeline as a branch graph. id is box:<boxId> for a box's rows (a box.created
joins its box through its create job), branch:<head> for a pull request no box is known to own, and
trunk for everything else: tasks without a box, managers, notes, plans. from is on a lane's
oldest row: the box lane that last carried its base (a PR lane uses pr.base), else trunk. into
is on pr.merged: the box lane carrying pr.base, else trunk; the row itself stays on its own
lane. branch is on a lane's first row and wherever its branch changes. open is on live rows and on
a lane's newest item when the lane goes on: its box has a live row, or still exists and is running or
has not merged its work. Lanes are assigned over the whole log before before and limit apply, so a
lane keeps its id on every page, and a page that does not reach a lane's fork has no from for it.
Who did it. The CLI sends X-AgentBox-Session: <agent>:<sessionId> from inside a Claude Code or
Codex session; the hub resolves it to a registered manager and stamps its managerId, turn and that
turn's prompt. Without the header (the tray, the web UI, a script) the actor is human, and so is a
manager of another workspace: on a box or manager route the session is resolved in that box's or
manager's own workspace. Alongside it the CLI sends X-AgentBox-Session-Turn: <turn>[;<prompt>] (the
prompt percent-encoded), read from the transcript on ITS machine — a control box holds the manager
record but not the transcript. It is used only when the resolved manager's session is on another
host; where the hub can read the transcript it does, and ignores the header. note
(up to 2000 characters) on a task add, update, assign or reorder body records a manager.note next to
the change.
Pull requests come from two places that share one dedupe key: the in-box gh shim after an
exit-0 gh pr create or gh pr merge, and a GitHub sync — gh pr list on the GitHub repos the
workspace's projects name — that a read starts in the background when the last one is over a minute
old. Every gh call it makes addresses the repo by name and runs in no folder, so a hub with no
checkout syncs exactly like one that has them; a project with no repoUrl has nothing to poll. It
keeps PRs on a branch the workspace worked on, or opened by the gh user. github is ok,
syncing or unavailable (no gh, not logged in, or no GitHub repo); rows a sync adds fire the
usual /api/events change.
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 + path | Description |
|---|---|
GET /agents | The 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 + path | Description |
|---|---|
GET /approvals | Pending host-action approvals. |
POST /approvals/{id}/answer | Answer 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}/stream | Payload-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 + path | Description |
|---|---|
GET /jobs | The 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}/logs | Stream the build log (SSE). |
POST /jobs/{id}/login-code | Deliver 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 + path | Description |
|---|---|
GET /custody | Metadata 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 + path | Description |
|---|---|
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, persistent (an always-on box — never auto-paused,
never idle-lapsed, skipped by prune, restarted by the relay's boot reconcile; see
persistent boxes. POST /boxes/{id}/destroy
has no guard on it, unlike agentbox destroy -y, so a client that offers destroy
should confirm on this field), 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, …).
Questions a create has to ask first
Some creates need a human decision before anything is provisioned: whether to
copy the host files a project's carry: block
names, or whether to seed a service agent with a host login. POST /projects/{id}/create-preflight returns those questions; you render them, and
send the answers back with the create.
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"agent":"claude","provider":"docker"}' \
http://127.0.0.1:8787/api/v1/projects/<id>/create-preflight
# -> { "prompts": [ { "id": "carry:98310458c334", "topic": "carry", "kind": "select",
# "title": "Copy 15 host entries into the box?",
# "choices": [ ... ], "detail": { "type": "file-table", ... },
# "required": true, "fallback": { "value": "cancel", ... } } ],
# "unavailable": [] }
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"projectId":"<id>","agent":"claude","opts":{"promptAnswers":[
{"id":"carry:98310458c334","value":"approve"}]}}' \
http://127.0.0.1:8787/api/v1/boxesOnce a project's carry: list is approved it stops being asked at all — the approval is remembered host-side (see carry), so the preflight returns no carry prompt until the list changes. Send carryAsk: true to show it again for a granted list; answering Skip or Cancel on the create that follows withdraws the standing approval.
A client that already holds the answer can skip the two-step conversation and post the decision instead of an answer list: opts.carryYes approves the block, opts.carrySkip declines it. Both are per-run and leave no standing grant, which makes them the API twins of the CLI's --carry-yes and --carry skip — and the way a scripted create gets through with no terminal to prompt at.
A PromptRequest has two halves. The generic one — kind (confirm /
select / text), title, body, choices — is enough to render and answer
any prompt, including a topic your client has never seen, so the hub can
add a question without breaking you. detail is the optional half a client
may draw richly (file-table, credential); every variant carries a plain
summary, so render that for one you do not recognise.
The list comes from running the real gates, so it can never differ from what the
create asks. Skipping the endpoint is allowed — every prompt has a fallback —
except for one marked required (carry:, whose silent answer would move host
secrets): a create that leaves that unanswered is refused, not defaulted. Ids
are content-addressed over the question, so an answer collected before the
project's agentbox.yaml changed no longer matches and is refused too.
unavailable names each gate this hub cannot run and why. A control box has no
local checkout, so it can read neither your agentbox.yaml nor your host
logins; it says so rather than returning an empty list that looks like "nothing
to ask".
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>/logsevent: 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>/logsOnce 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.