Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

HTTP API & events

prosperod serves one HTTP surface. The CLI, the dashboard and other clients such as ariel all use it. Requests and responses are JSON. The request and response types live in the prospero-types crate, which the WASM dashboard also uses, so these shapes are the ones the server actually serializes.

The OpenAPI document

Everything this page describes in prose is also published as an OpenAPI 3.1 document at GET /api/openapi.json, so a client can generate its request and response types instead of hand-writing them:

curl -s http://127.0.0.1:7777/api/openapi.json | jq .

The route is open — you need the document to work out how to authenticate, and it describes the API's shape rather than any fleet data. Each operation records the scope it requires in an x-prospero-scope field, so the table below and the document never disagree about who may call what.

For code generation in CI, where no daemon is running, the same bytes come from the repository:

cargo run -p prospero-api --example openapi > openapi.json

The schemas are derived from the same prospero-types structs the server serializes, so they cannot drift from the wire. Where a type's read shape differs from its write shape — a field with a serde default is optional when sent but always present when returned — the request form is published under a …Request name, and the request body points at that one.

Authentication

When prosperod runs with a tokens file, every route except the open ones requires a credential: an Authorization: Bearer pspo_… header, or the dashboard's session cookie. Each route below lists the minimum scope it needs. Routes not in the table fail closed to admin. See Securing the API.

Errors

Errors come back as {"error": "<message>", "kind": "<kind>"}:

StatuskindWhen
400provider_misconfigured, invalid_configBad provider or workspace configuration
400bad_requestA malformed request: for example a /api/usage window that isn't a timestamp, or an invalid X-Prospero-On-Behalf-Of header
401unauthorizedMissing, invalid or expired credential (with WWW-Authenticate: Bearer realm="prospero")
403forbiddenScope too low, or a cross-origin mutation made with a session cookie
404not_foundUnknown agent, workspace or automation
405method_not_allowedThe active fleet backend has no workspace admin plane wired, or automations have no config store
409invalid_state, conflictFor example, registering a workspace name that already exists
502protocolcaliband answered with something unexpected
503unreachablecaliband (or the fleet backend) could not be reached
500internalStore or I/O failure

Routes

MethodPathScopePurpose
GET/ , /assets/{*path}openThe dashboard
GET/healthzopenLiveness: always 200 ok while the process is up
GET/readyzopenReadiness: 200 when the event store is writable, else 503
GET / POST / DELETE/api/sessionopenDashboard sign-in, whoami, sign-out
GET/api/openapi.jsonopenThis API as an OpenAPI 3.1 document
GET/api/capabilitiesreadWhat the active backend supports
GET/api/metricsreadOperational counters
GET/api/fleetreadFleet snapshot: every workspace and its agents
GET/api/fleet/stream?from=N|nowreadEvery stream's events, replay then tail (SSE)
POST/GET/mcpoperateDrive the fleet over the Model Context Protocol
GET/api/usagereadCost, turns and outcomes per workspace per day
GET/api/workspacesreadWorkspaces with health, sources, agent count and config
POST/api/workspacesadminRegister a workspace
PUT/api/workspaces/{name}/configadminReplace a workspace's configuration
DELETE/api/workspaces/{name}adminUnregister a workspace
GET/api/workspaces/{workspace}/agentsreadAgents under one workspace
POST/api/workspaces/{workspace}/agentsoperateSpawn an agent
GET/api/agents/{id}readOne agent's current state
DELETE/api/agents/{id}operateRemove an agent
GET/api/agents/{id}/events?from=NreadDurable event history (JSON array)
GET/api/agents/{id}/stream?from=NreadReplay history, then tail live (SSE)
POST/api/agents/{id}/killoperateKill a running agent (202)
POST/api/agents/{id}/respawnoperateReplace an agent with a fresh one using the same spec
POST/api/agents/{id}/inputoperateSend a user message to an interactive agent (202)
POST/api/agents/{id}/end-inputoperateSignal end-of-input to an interactive agent (202)
GET/api/automationsreadEvery automation. Never includes a webhook signing key
POST/api/automationsadminCreate an automation (201). A webhook's signing key is returned once, here
DELETE/api/automations/{id}adminDelete an automation and its run history
PUT/api/automations/{id}/enabledadminEnable or disable an automation without deleting it
GET/api/automations/{id}/runs?limit=NreadRecent runs, newest first (default 50)
POST/api/automations/{id}/runoperateFire an automation now, whatever its trigger
POST/api/automations/{id}/triggeropen (signed)Fire a webhook automation. The HMAC signature is the credential; see Automations

HEAD is accepted wherever GET is.

Workspaces

POST /api/workspaces takes:

{ "name": "myproj", "root": "/home/me/src/myproj", "config": { "provider": "anthropic" } }

root is the workspace directory on the local backend. It is ignored under k8s, where sources come from config. config is optional. It is the same object PUT /api/workspaces/{name}/config takes as its whole body. Each backend reads only the fields it uses:

FieldBackendMeaning
providerlocalProvider id, exported to caliband as CALIBAN_PROVIDER
base_urllocalProvider base URL, for example ANTHROPIC_BASE_URL or OPENAI_BASE_URL
api_key_from_envlocalName of an env var in prosperod's environment whose value becomes the provider's API key
envlocalRaw KEY: value overrides. Stored and returned verbatim, so never put secrets here
display_namek8sDashboard label
sourcesk8s[{name, repo, ref?, path}] git checkouts
providersk8s[{name, kind, base_url?, model?, credentials_ref?: {secret_name, key}}]
default_providerk8sProvider bound when a spawn names none
isolationk8s{runtime_class?, worktrees?}. runtime_class is a Kubernetes RuntimeClass (gvisor, kata) the operator puts on the agent's pod. worktrees is stored but does not reach an API-spawned agent — see Spawning

On the local backend, POST returns 201 and PUT returns 204. Both apply immediately; setting the config restarts the workspace's caliband. Under k8s the change is written to a Workspace custom resource and reconciled asynchronously, so both return 202. GET /api/capabilities reports which applies:

{ "admin": true, "async_workspace_ops": false }

Spawning

POST /api/workspaces/{workspace}/agents:

{
  "prompt": "refactor the parser",
  "label": "parser",
  "model": "…",
  "isolation": "worktree",
  "interactive": false,
  "tool_allowlist": ["read", "edit"],
  "frontmatter_path": "/path/to/agent-template.md",
  "provider_ref": "planner",
  "timeout_secs": 3600,
  "permission_posture": "supervised"
}

Only prompt is required. provider_ref picks a named provider under k8s and is ignored by the local backend, which uses the workspace's stored config.

isolation is honoured by the local backend, where it defaults to a git worktree and only the exact string "shared" opts out — an unrecognized value isolates rather than silently dropping isolation.

Under k8s, a spawn runs in the workspace's shared checkout. The spawn's isolation is deliberately not written to the CalibanTask — per-run isolation is not a field the frozen CRD has, and isolation defaults are meant to live on the Workspace. prospero reads the worktree strategy back from the task's spec.isolation.worktrees and isolates only on the exact value "per-source", but nothing populates that field for a task prospero created: prospero leaves it unset, and caliban-operator's only Workspace-to-task isolation fallback is runtime_class, which it applies straight to the pod. So an agent spawned through the API gets the shared checkout whatever the request or the workspace config says, and --shared-tree makes no difference there.

Worktree isolation by default (ADR 0005) is therefore a local-backend property today: parallel agents on one k8s workspace share a tree and can step on each other.

timeout_secs caps the agent's wall-clock life. Prospero — not the agent — enforces it: the deadline is written to the event log at spawn, so it survives the replica that set it, and any replica holding the workspace's lifecycle lease kills the agent once it passes. The log records an agent_timed_out event before the killed transition, so history says why the agent stopped, and /api/usage counts those runs separately as timed_out (they are also counted in killed, because the kill is real). Absent means no limit.

permission_posture is "supervised" (the default, and what an absent field means) or "unattended". Supervised keeps the agent's permission gate: a tool call that needs approval is refused unless a human answers it. Unattended turns the gate off, so every tool runs without asking — it needs an admin token, and a request with a lesser scope is refused with 403 and {"error": "unattended requires admin scope"}. Each granted unattended spawn is recorded in the audit log with the token's name.

Under k8s the field is written to the CalibanTask as spec.task.permissionPosture and is a request: the operator admits unattended only when the Workspace sets spec.agentPolicy.allowUnattended: true, and otherwise fails the task with PostureNotPermitted before any sandbox starts. prospero runs the agent with the posture the operator admitted (status.permissionPosture), so a cluster whose operator predates that field (caliban-operator ≤ v0.5.0) runs every session supervised. It needs caliban ≥ v0.14.0 in the sandbox.

Each agent in /api/fleet and /api/agents/{id} reports its own permission_posture — the posture it is actually running under, not the one the spawn asked for. Under k8s that is the operator's status.permissionPosture; a task whose status does not carry one (an operator predating the field) reads back as supervised, which is also how an absent field deserializes from an older prosperod's snapshot.

The response is 201:

{ "agent_id": "…", "workspace": "myproj", "isolated": true, "created": true }

created is false when the request matched an agent that was already running instead of starting a new one. Spawning is idempotent under k8s.

POST /api/agents/{id}/respawn returns {"agent_id": "<new id>"}. The old id leaves /api/fleet and /api/agents/{id}, but its history is still available from /api/agents/{old id}/events.

MCP

/mcp exposes the fleet as an MCP server (streamable HTTP), so an agentic client can drive prospero with tools instead of hand-written HTTP calls. It is a thin adapter over the same seam the REST handlers use, so it works on both backends.

ToolDoes
prospero_list_workspacesWorkspaces, health, agent counts
prospero_list_agentsEvery agent with status and workspace
prospero_spawn_agentLaunch an agent (workspace, prompt, optional label, model, interactive, timeout_secs)
prospero_agent_statusOne agent's current state
prospero_agent_eventsRecorded events from from onward, capped (100 default, 500 max) with truncated + next_from
prospero_send_input / prospero_end_inputSteer an interactive agent
prospero_kill_agent / prospero_respawn_agentStop or restart an agent

The whole endpoint requires the operate scope — its tools spawn, steer and kill, and one scope for the surface is simpler to reason about than a second, per-tool authorization model inside the handler. Workspace administration (add/remove/configure) is deliberately not exposed; use the REST API with an admin token.

A spawn is visible to prospero_list_agents from the next poll, exactly as it is on GET /api/fleet — the fleet view is the poll snapshot.

The fleet stream

GET /api/fleet/stream is one SSE connection carrying every agent's events, for a consumer that wants the whole fleet rather than one agent — a notifier, a dashboard, an external bridge. Without it, watching a fleet meant one connection per agent, and no way to learn about an agent you had not seen yet.

Each event arrives as JSON with its fleet cursor in the SSE id: field:

id: 4821
data: {"seq":7,"ts":"…","repo":"myproj","agent_id":"a1","kind":{…}}
  • ?from=<cursor> resumes after that cursor: no gap, no repeat.
  • ?from=now skips history and delivers only what happens next — what a notifier wants on restart, so it does not re-announce everything it already handled.
  • Omitted replays from the beginning of the store.

The cursor is durable insertion order across all streams, which is the only total order a fleet has: a per-agent seq cannot order two different agents, since both start at 1.

Events are read from the durable store rather than from the live bus, so a clustered deployment delivers each event exactly once no matter which replica produced it, and a slow client cannot be skipped past — the per-agent stream's gap signal has no counterpart here because there is nothing to miss.

Why an agent is in its state

An agent in /api/fleet and /api/agents/{id} may carry reason and detail — present only when something outside the agent decided its state, and omitted entirely otherwise.

Under k8s they come from the operator's Ready condition on the CalibanTask. The case worth knowing: a task that asks for permission_posture: "unattended" under a Workspace that does not set spec.agentPolicy.allowUnattended: true is failed by the operator with reason: "PostureNotPermitted" before any sandbox is created — so there is no pod, no caliband and no agent-side error, and this field is the only account of it. The spawn itself still returns 201: the refusal happens during reconciliation, after the API has answered.

Local (non-k8s) agents never carry these: caliband reports their state directly, with no admission step above it.

Usage

GET /api/usage aggregates agent_finished accounting and terminal outcomes from the event store. Query parameters, all optional:

  • until: exclusive end, RFC 3339. Defaults to now.
  • since: inclusive start, RFC 3339. Takes precedence over days.
  • days: window length counted back from until. Defaults to 7, minimum 1, maximum 36500 (a century).

A bound that isn't an RFC 3339 timestamp, or a days past the maximum, is a 400 bad_request. Neither is quietly answered with some other window. Any offset is accepted: …T00:00:00Z and …T00:00:00+00:00 select exactly the same events, and the echoed window is normalized to UTC.

The response echoes the window it used and returns one group per workspace. Each group has totals and a per-UTC-day series:

{
  "since": "…", "until": "…",
  "groups": [{
    "workspace": "myproj", "cost_usd": 1.75, "turns": 6,
    "outcomes": { "done": 2, "failed": 0, "killed": 1, "crashed": 0, "timed_out": 1 },
    "series": [{ "day": "2026-09-01", "cost_usd": 0.75, "turns": 4, "outcomes": { "…": 0 } }]
  }]
}

outcomes always carries all five keys. timed_out is a breakdown of killed, not a fifth terminal state: those runs are counted in both, so summing done + failed + killed + crashed gives the total and adding timed_out would double-count.

Automations

An automation spawns an agent on a trigger: a cron schedule, or a signed webhook. Automations are stored in the shared config store, so they need one. The local backend always has one. Under k8s, prosperod needs --database-url; without it, every automation route returns 405 method_not_allowed.

POST /api/automations takes:

{
  "id": "nightly-sweep",
  "workspace": "myproj",
  "trigger": { "kind": "cron", "schedule": "0 3 * * *" },
  "template": { "task": "Sweep the logs and file anything new", "timeout_secs": 1800 },
  "enabled": true
}

template is the spawn each firing makes. task is required. The optional fields are label, model, tool_allowlist, frontmatter_path, provider_ref, timeout_secs, isolation_worktree (default true) and permission_posture (default supervised). An automation's runs are never interactive: nobody is there to answer. Creating an automation needs admin, because it can choose unattended for runs that nobody watches.

Schedules are 5-field cron in UTC. prosperod checks them every --automation-interval-ms (default 30 s), so a firing can land up to one interval after its tick. If prosperod was down across several ticks, it fires once for the most recent tick when it comes back, rather than catching up every missed one. With several replicas, each tick fires exactly once: the replicas race to claim it in the config store, and only one wins.

Webhooks. Create one with "trigger": { "kind": "webhook" }. The 201 response carries a webhook_secret. This is the only time the key is returned. It never appears in a listing, so store it when you create the automation. To fire it:

body='{"ref":"main","pusher":{"name":"ada"}}'
sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$KEY" -hex | sed 's/^.* //')
curl -X POST "$PROSPEROD/api/automations/deploy-check/trigger" \
  -H "X-Prospero-Signature: sha256=$sig" -d "$body"

The signature is HMAC-SHA256 over the raw request body, exactly as sent, because re-encoding parsed JSON changes the bytes. The route takes no token: the signature is the credential, and it authorizes firing that one automation only. A missing or wrong signature is a 401, as is triggering an automation that has no webhook (it has no key to sign with). A disabled automation is a 409.

A webhook's task can use {{ dotted.path }} placeholders, filled from the JSON payload: "Review the push to {{ ref }} by {{ pusher.name }}". The payload only fills slots the automation's author placed. It is data, not instruction. Each value is cut at 4096 characters. If a placeholder's path is missing or null, the run is recorded as failed and no agent is spawned on a half-formed prompt.

Runs. Every firing, whether schedule, webhook or manual (POST …/run), is recorded:

{ "automation_id": "nightly-sweep", "fired_at": "2026-09-20T03:00:00Z",
  "source": "schedule", "agent_id": "…" }

A failed firing has error in place of agent_id. The last 200 runs are kept for each automation. GET …/runs returns 50 by default.

Events

Every observation becomes a FleetEvent:

{
  "seq": 42,
  "ts": "2026-09-13T12:00:00Z",
  "repo": "myproj",
  "agent_id": "…",
  "kind": { "kind": "tool_started", "id": "tu_1", "name": "Read", "input": {} },
  "actor": "alice",
  "on_behalf_of": "discord:U123"
}

seq increases monotonically within an agent's stream. repo holds the owning workspace name; the field keeps its original name for wire compatibility. actor appears only on events a token-authenticated request caused directly, which today means local-fleet spawn and remove. on_behalf_of is the person the client said it was acting for, from the X-Prospero-On-Behalf-Of header; it appears beside actor on the same events and is the client's unverified assertion, not an authenticated identity — see Acting for someone else. Both fields are omitted entirely when absent.

kindFieldsMeaning
agent_spawned—Prospero asked for this agent
agent_discovered—A poll found an agent Prospero had not seen
agent_initmodel, tools, session_idThe agent's run started
status_changedfrom, toLifecycle transition (spawning, running, idle, killed, done, failed, crashed)
outputstream (stdout or thinking), chunkStreamed text
tool_startedid, name, inputA tool call began
tool_finishedid, name, ok, result?, truncated?A tool call ended. Pair it with its start by id; name is usually empty. result is what the tool returned as text, capped at 8,192 characters; truncated: true means it is only the start of a longer result. Both are omitted when absent (events recorded before results were captured, or no result sent)
agent_finishedoutcome, cost_usd, turnsTerminal accounting
deadline_setdeadlineThe spawn carried timeout_secs; this is the RFC-3339 instant it implies, recorded at spawn so it outlives the replica that set it
agent_timed_outdeadlineThe agent passed that deadline and prospero killed it. Emitted before the status_changed to killed, so history says why
agent_gone—The agent left caliband's registry
repo_healthstateA workspace's caliband became healthy or unreachable
store_persist_failedlost_seq, detailEvent lost_seq went out live but is missing from durable history

Model reasoning (stream: "thinking") is dropped by default. The k8s backend includes it when PROSPERO_INCLUDE_THINKING=1.

Clients should ignore kind values they don't recognize.

The SSE stream

GET /api/agents/{id}/stream?from=N first replays stored events with seq >= N, then tails live events, with no gap or duplicate between the two phases. Each event is an unnamed SSE message whose data is one FleetEvent. The stream closes after agent_finished, so a finished run reads like tail of a file and does not hang. Periodic keep-alive comments are sent while idle.

A consumer that falls far enough behind to overflow its live buffer does not silently lose events. The server sends a named gap event, {"skipped": <n>, "last_seq": <seq>}, then replays the missed events from the durable store. To resume after a disconnect, reconnect with from = last seen seq + 1.

curl -N -H "Authorization: Bearer $PROSPERO_TOKEN" \
  "http://127.0.0.1:7878/api/agents/$AGENT/stream?from=0"