prospero
Prospero is the control plane for fleets of caliban agents. You use it to launch, manage and observe many agents across many workspaces, including several agents working in parallel on the same codebase.
It has three parts:
prosperod, a long-running daemon that serves an HTTP/JSON API, Server-Sent Events streams and an embedded web dashboard on127.0.0.1:7878by default.prospero, an operator CLI. It is a thin HTTP client over that API.- The dashboard, a Rust → WASM app served by
prosperodat/.
The CLI, the dashboard and any other client all use the same public API. That API
is published as an OpenAPI 3.1 document, and
prosperod also exposes the fleet as an MCP server at /mcp, so
an agentic client can drive it with tools instead of hand-written HTTP calls.
How it works
Caliban already ships a per-workspace supervisor, caliband, which spawns and
manages background agents over a Unix-socket NDJSON protocol. Prospero sits
above many calibands:
prospero CLI ─┐
dashboard ────┼─ HTTP/JSON + SSE ─▶ prosperod ─┬─ local backend: caliband per workspace (Unix sockets)
other clients ┘ └─ k8s backend: CalibanTask / Workspace custom resources
- Launch. Spawn agents under any registered workspace. Each spawn runs in an isolated git worktree by default. Sharing the working tree is an explicit opt-out.
- Manage. List, kill, respawn and remove agents, and send input to interactive agents. A spawn may carry a wall-clock timeout that Prospero — not the agent — enforces, and a permission posture that decides whether tool calls needing approval wait for a human.
- Observe. Prospero turns caliban's stream output into a stable
FleetEventtype. Events go out live over SSE — per agent, or as one fleet-wide stream — and are also written to a durable store (sqlite standalone, Postgres clustered), so an agent's history survives after it finishes./api/usageaggregates that history into cost, turns and outcomes per workspace. - Automate. An automation spawns an agent on a cron schedule or a signed webhook, so recurring work needs nobody at a keyboard.
Prospero talks to caliban only through its wire format. It depends on no caliban crate (ADR 0003).
The caliban-ai ecosystem
| Project | Role |
|---|---|
| caliban | The agent harness, plus caliband, the supervisor Prospero drives on the local backend. |
| prospero | This project: the fleet control plane. |
| gonzalo | A shareable persistence layer for caliban. |
| ariel | A chat bridge for the fleet: Discord today, Slack and Teams planned. |
| caliban-operator | Reconciles CalibanTask and Workspace resources into caliband pods for Prospero's k8s backend (ADR 0008). |
Ariel is a separate service that brings fleet notifications, commands and
conversation into chat. It uses Prospero only through the public HTTP + SSE API
described in this guide, typically with an operate-scoped
API token. It does not depend on any Prospero crate. Ariel keeps
its own identity, channel configuration and audit data in gonzalo.
Ariel is released and running: the current release ships /ariel status,
/ariel spawn, /ariel kill and /ariel respawn for driving the fleet from
Discord, plus /ariel channel, /ariel configure and /ariel invite for
channel administration and onboarding, with every command authorized against the
person's role and the channel's ceiling and audited in gonzalo. It is distributed
as the container image ghcr.io/caliban-ai/ariel rather than on crates.io, and
it can reach a prosperod over https through an ingress as well as in-cluster.
See Ariel's guide and its
releases for the current state.
Where to go next
- Getting started: build and run
prosperod, register a workspace, spawn an agent. - The
prosperoCLI: every command and flag. - Configuring prosperod: flags, environment, storage topology, fleet backends.
- HTTP API & events: routes, payloads, the SSE stream and event kinds.
- Securing the API: tokens, scopes, dashboard sessions.
- Deploying the container: the
ghcr.io/caliban-ai/prosperoimage, including the k8s backend. - Guiding principles and the ADRs: the reasoning behind the design.
- The API reference: rustdoc for the crates.
Getting started
This page runs prosperod locally against caliban, using the default local
backend, then launches and watches an agent. To run it in a container or on
Kubernetes instead, see Deploying the container.
Prerequisites
- A recent stable Rust toolchain. The workspace uses edition 2024.
- caliban's
calibandon yourPATH.prosperodstarts one per workspace when none is running. Use--caliband-binto point it at a specific binary. - Credentials for whichever model provider your agents will use.
Build
git clone https://github.com/caliban-ai/prospero
cd prospero
cargo build --release -p prospero-daemon -p prospero-cli
# binaries: target/release/prosperod and target/release/prospero
The dashboard is compiled into prosperod, so the build needs no wasm
toolchain.
Run the daemon
prosperod
By default it:
- listens on
127.0.0.1:7878(the API, SSE and the dashboard at http://127.0.0.1:7878/); - keeps its workspace registry and event history in sqlite (
events.db) under$XDG_DATA_HOME/prospero, or~/.local/share/prospero; - runs without authentication, which it allows only on a loopback address.
To expose it beyond loopback, set up tokens first; see Securing the API. Every flag is listed in Configuring prosperod.
Register a workspace
A workspace is a directory with a name. The directory is either a git checkout itself or a folder whose immediate subdirectories are checkouts; each checkout is one of the workspace's sources.
prospero workspace add myproj ~/src/myproj
prospero workspace list
Tell the workspace's caliband which provider to use. --api-key-env names a
variable in prosperod's environment; the secret itself is never stored.
prospero workspace config myproj --provider anthropic --api-key-env ANTHROPIC_API_KEY
# a local OpenAI-compatible server needs no key:
prospero workspace config myproj --provider openai --base-url http://127.0.0.1:8080/v1
Setting the config restarts that workspace's caliband.
Launch and watch an agent
prospero spawn myproj "add tests for the parser"
# spawned agent <id> in workspace 'myproj' (worktree)
prospero ls # every workspace and its agents
prospero follow <id> # replay the agent's events so far, then stream live
Each spawn gets its own git worktree by default, so parallel agents on one
codebase don't step on each other. Pass --shared-tree to run in the workspace's
own working tree.
follow exits when the agent finishes. The agent's history stays in the event
store afterwards, so you can follow it again later.
Interactive agents
An interactive agent waits for input when its run finishes, instead of exiting:
prospero spawn myproj "let's plan the migration" --interactive
prospero send <id> "start with the schema"
prospero end-input <id> # it finishes after the current run
Other lifecycle commands
prospero kill <id>
prospero respawn <id> # same spec, new agent id
prospero rm <id>
The full list is in The prospero CLI.
The dashboard
Open http://127.0.0.1:7878/. The dashboard uses the same API as the CLI and offers:
- the fleet overview, with spend, turns and outcome charts over a 24h, 7d or 30d
window, and a
bypasstag on any agent running unattended; - launching, killing, respawning and removing agents, and replying to interactive agents;
- registering, configuring and removing workspaces;
- a live stream and timeline for each agent, with a tool-call inspector that
shows a call's input and its result, and a collapsible "Thinking" segment
when the daemon is streaming model reasoning (
PROSPERO_INCLUDE_THINKING); - a System, Light or Dark theme setting.
Controls the active backend can't serve are hidden (GET /api/capabilities).
When authentication is on, the dashboard asks for a token first and hides
controls above that token's scope.
The prospero CLI
prospero is a thin client over prosperod's HTTP API. Apart from
token new, every command sends an HTTP request to the daemon. Run
prospero --help or prospero <command> --help for the built-in help.
Global options
| Option | Env | Default | Purpose |
|---|---|---|---|
--addr <URL> | PROSPERO_ADDR | http://127.0.0.1:7878 | Base URL of prosperod |
--token <TOKEN> | PROSPERO_TOKEN | — | API token for an authenticated daemon |
--token-file <PATH> | PROSPERO_TOKEN_FILE | — | File containing the token (trailing whitespace trimmed); wins over --token |
--on-behalf-of <WHO> | PROSPERO_ON_BEHALF_OF | — | Name the person this command is run for, recorded beside the token on the events it causes. Asserted, not verified; at most 128 characters and no control characters. See Acting for someone else |
The CLI and `prosperod` both read `PROSPERO_ADDR`, but they expect different
values. The CLI wants a full base URL (`http://host:7878`). `prosperod` wants a
bind address (`0.0.0.0:7878`). Avoid exporting a daemon-style value in a shell
where you run the CLI.
Workspaces
| Command | Purpose | Scope |
|---|---|---|
prospero workspace add <name> <root> | Register a workspace by name and root path | admin |
prospero workspace list | List workspaces with health, agent count, sources and provider | read |
prospero workspace config <name> [flags] | Set the workspace's provider config and restart its caliband | admin |
prospero workspace rm <name> | Unregister a workspace | admin |
workspace config flags:
| Flag | Meaning |
|---|---|
--provider <ID> | Provider id, for example anthropic, openai or google. Leave it out to clear the provider |
--base-url <URL> | Provider base URL |
--api-key-env <VAR> | Name of an env var in prosperod's environment that holds the API key |
--env KEY=VALUE | Raw env override (repeatable). Stored verbatim, so don't put secrets here |
Each call replaces the whole configuration. Flags you leave out are cleared.
Agents
| Command | Purpose | Scope |
|---|---|---|
prospero spawn <workspace> <prompt> [flags] | Launch an agent (worktree-isolated by default) | operate |
prospero ls | List the fleet: every workspace and its agents | read |
prospero status | Report whether the daemon is reachable, then list the fleet | read |
prospero follow <id> [--from N] | Replay events from seq N (default 0), then stream live until the agent finishes | read |
prospero kill <id> | Kill a running agent | operate |
prospero respawn <id> | Kill and respawn with the same spec; prints the new id | operate |
prospero rm <id> | Remove an agent from caliban's registry | operate |
prospero send <id> <text> | Send a user message to an interactive agent | operate |
prospero end-input <id> | Signal end-of-input to an interactive agent | operate |
spawn flags:
| Flag | Meaning |
|---|---|
--label <TEXT> | Human-readable label |
--model <MODEL> | Model override |
--shared-tree | Run in the workspace's working tree instead of an isolated git worktree. Local backend only — under k8s every spawn uses the shared checkout regardless, see Spawning |
--interactive | Wait for operator input after each run instead of finishing |
--tool-allowlist <TOOL> | Restrict the agent to these tools; repeat once per tool |
--frontmatter <PATH> | Agent-template / frontmatter markdown file |
--timeout <SECONDS> | Kill the agent after this much wall-clock time. Enforced by prosperod, so it survives a daemon restart; the log records why |
--permission-posture <POSTURE> | supervised (default) keeps the agent's permission gate; unattended runs every tool without asking. unattended needs an admin token, and in-cluster a workspace that allows it |
follow prints output text inline, one line per tool start and finish, and
short markers for init, status and finished. A [gap] line means the
stream fell behind and recovered the dropped events from history. A
[persist-gap] line means an event could not be written to durable storage.
Automations
An automation spawns an agent on a cron schedule or a signed webhook. See Automations in the API guide for how triggers, signing and run history work. The dashboard does not show automations, so these commands and the API are how you manage them.
| Command | Purpose | Scope |
|---|---|---|
prospero automation add <id> <workspace> <task> [flags] | Create an automation | admin |
prospero automation ls | List automations: trigger, workspace, last firing | read |
prospero automation run <id> | Fire one now, whatever its trigger | operate |
prospero automation runs <id> [--limit N] | Recent runs, newest first (default 20) | read |
prospero automation disable <id> | Stop it firing, without deleting it | admin |
prospero automation enable <id> | Let a disabled automation fire again | admin |
prospero automation rm <id> | Delete it and its run history | admin |
add needs exactly one trigger:
| Flag | Meaning |
|---|---|
--schedule <CRON> | 5-field cron, in UTC, for example "0 3 * * *" |
--webhook | Fire on a signed POST. Prints the signing key |
--label <TEXT> | Label for the agents it spawns |
--model <MODEL> | Model override |
--timeout-secs <SECONDS> | Kill each spawned agent after this long |
--shared-tree | Run in the workspace's working tree instead of an isolated worktree |
--disabled | Create it without letting it fire yet |
With --webhook, add prints the trigger URL and the signing key.
The key is shown once and can't be recovered. Store it before you do
anything else. With --webhook, the task can use {{ dotted.path }}
placeholders filled from the request payload.
Automations created with the CLI always run in the supervised permission
posture. To create an unattended one, send permission_posture in the
template through the API.
prospero automation add nightly myproj "Sweep the logs" --schedule "0 3 * * *"
prospero automation add deploy-check myproj "Review the push to {{ ref }}" --webhook
Usage
prospero usage reports cost, turns and outcomes per workspace over a window.
It reads GET /api/usage, the same report the dashboard draws, so the
numbers match what the dashboard shows for the same window. Needs read.
$ prospero usage --since 2w
usage 2026-09-06 20:50 UTC → 2026-09-20 20:50 UTC
WORKSPACE COST TURNS DONE FAILED KILLED CRASHED
alpha $1.2500 40 3 1 0 0
beta $0.5000 2 1 1 0 0
TOTAL $1.7500 42 4 2 0 0
| Flag | Meaning |
|---|---|
--since <WHEN> | How far back to look. A day count (7d, 2w), a date (2026-09-01, from UTC midnight) or an RFC-3339 timestamp. Defaults to the last 7 days |
--workspace <NAME> | Only this workspace |
--json | Print the server's report as JSON instead of the table |
Usage is recorded per UTC day, so --since looks back in whole days. It
refuses 24h rather than implying a precision the report doesn't have. A day
count is resolved against the daemon's clock, the way the dashboard does it,
so a skewed laptop clock can't clip the window.
A workspace with no activity in the window doesn't appear in the report, so
--workspace can't tell an idle workspace from a misspelled one. Either way it
says that no usage was recorded. killed includes runs stopped by their
--timeout, and a note under the table says how many.
Tokens
| Command | Purpose |
|---|---|
prospero token new <name> --scope <read|operate|admin> | Generate a token. Runs offline and never contacts prosperod |
prospero whoami | Show the token name and scope in use, or report that auth is disabled |
token new prints the token once, together with the line to add to prosperod's
tokens file. Token names must match [a-z0-9][a-z0-9_-]{0,62}. See
Securing the API.
Configuring prosperod
prosperod is configured with command-line flags. Most flags also read an
environment variable. Run prosperod --help for the authoritative list.
Core flags
| Flag | Env | Default | Purpose |
|---|---|---|---|
--addr | PROSPERO_ADDR | 127.0.0.1:7878 | Bind address for the API and dashboard |
--data-dir | PROSPERO_DATA_DIR | $XDG_DATA_HOME/prospero, else ~/.local/share/prospero | Standalone storage directory |
--host | PROSPERO_HOST | local | Host identity reported in fleet snapshots |
--fleet-backend | PROSPERO_FLEET | local | local (caliband over Unix sockets) or k8s |
--poll-interval-ms | — | 2000 | How often the local backend polls each caliband |
--automation-interval-ms | — | 30000 | How often due automation schedules are checked. Cron resolves to whole minutes, so this bounds only how late a firing can be |
--no-autostart | — | off | Don't start caliband for a workspace that has none running |
--caliband-bin | — | caliband | caliband binary used for autostart |
--default-env KEY=VALUE | — | — | Env var applied under every workspace's config (repeatable) |
--retention-days | — | 0 (off) | Hourly sweep deleting events older than N days |
Logging goes to stderr. The level is controlled by RUST_LOG, default info;
for example, RUST_LOG=prosperod=debug,info.
prosperod shuts down gracefully on Ctrl-C or SIGTERM.
Authentication flags
| Flag | Env | Purpose |
|---|---|---|
--api-tokens-file | PROSPERO_API_TOKENS_FILE | Tokens file; when set, every non-open route needs a token |
--session-key-file | PROSPERO_SESSION_KEY_FILE | Dashboard session-cookie key, at least 32 bytes |
--insecure-no-auth | PROSPERO_INSECURE_NO_AUTH | Serve unauthenticated on a non-loopback address |
--cookie-secure | PROSPERO_COOKIE_SECURE | Always mark the session cookie Secure |
The startup rules are:
- Without tokens,
prosperodrefuses a non-loopback--addrunless given--insecure-no-auth. --insecure-no-authcannot be combined with a tokens file.- A clustered deployment with tokens requires a shared session key.
Details are in Securing the API.
Storage topology
Where history and configuration live depends on whether a Postgres URL is set:
| Standalone (default) | Clustered | |
|---|---|---|
| Selected by | no --database-url | --database-url / PROSPERO_DATABASE_URL |
| Event store + workspace registry | sqlite events.db in the data dir | Postgres |
| Live event bus | in-process | Postgres LISTEN/NOTIFY |
| Stream ownership | this process owns every stream | per-stream leases, so replicas fail over without double-writing |
The schema is created on startup; there is no separate migration step.
Automations live in the same config store, and the store
is what arbitrates which replica fires a given tick. The local backend always
has one — sqlite standalone, Postgres clustered. The k8s backend has no
sqlite config store, so automations there need --database-url; without it the
automation routes answer 405 method_not_allowed rather than firing once per
pod.
Clustered-only flags:
| Flag | Env | Default | Purpose |
|---|---|---|---|
--replica-id | PROSPERO_REPLICA_ID | $HOSTNAME | Lease identity; must be unique per replica |
--lease-ttl-secs | — | 30 | A stream's owner must renew within this window |
--heartbeat-interval-ms | — | a third of the TTL | How often held leases are renewed |
/readyz reports 503 whenever the event store can't accept writes, so an
orchestrator can hold traffic back.
Fleet backends
local
The local backend drives one caliband per registered workspace. prosperod
finds each caliband's control socket the same way caliban does:
$CALIBAN_DAEMON_RUNTIME_DIR, else $XDG_RUNTIME_DIR/caliban, else
$TMPDIR/caliban-daemon, with the socket named after a hash of the workspace's
canonical root. If no daemon is reachable and autostart is on, prosperod runs
<caliband-bin> --workspace-root <root> and waits up to 10 seconds for the
socket to appear.
A workspace's provider config becomes environment variables on its caliband. They are layered from lowest to highest precedence:
--default-envvalues.- The curated provider fields.
providerbecomesCALIBAN_PROVIDER. Foranthropic,openaiandgoogle,base_urlbecomesANTHROPIC_BASE_URL,OPENAI_BASE_URLorGEMINI_BASE_URL, andapi_key_from_envfillsANTHROPIC_API_KEY,OPENAI_API_KEYorGEMINI_API_KEYfrom the named variable in prosperod's own environment. - The workspace's raw
envmap.
For a local OpenAI-compatible model server, use provider openai with a
base_url pointing at its /v1 endpoint. A provider with an overridden
base_url may omit the API key. (Caliban's dedicated ollama provider was
removed in v0.7.0.)
k8s
With PROSPERO_FLEET=k8s, agents are CalibanTask custom resources and
workspaces are Workspace custom resources. The caliban-operator reconciles
them. Under this backend, prosperod runs no poll loop and starts no caliband.
It needs a binary built with --features k8s; the published container image is.
| Flag | Env | Default | Purpose |
|---|---|---|---|
--kubeconfig | KUBECONFIG | in-cluster, then ambient kubeconfig | API-server connection |
| — | PROSPERO_K8S_NAMESPACE | default | Namespace for CalibanTask and Workspace resources |
--k8s-caliband-ca-file | PROSPERO_K8S_CALIBAND_CA_FILE | unset | Enables TLS to each agent pod's session plane |
--k8s-caliband-token-file | PROSPERO_K8S_CALIBAND_TOKEN_FILE | unset | Bearer token for the session plane (requires the CA file) |
--k8s-caliband-server-name | PROSPERO_K8S_CALIBAND_SERVER_NAME | caliband | Name checked against the pod's TLS certificate (SNI) |
| — | PROSPERO_INCLUDE_THINKING | off | 1 records model reasoning as thinking output |
Both storage topologies work under k8s. See Deploying the container for the full k8s notes.
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>"}:
| Status | kind | When |
|---|---|---|
| 400 | provider_misconfigured, invalid_config | Bad provider or workspace configuration |
| 400 | bad_request | A malformed request: for example a /api/usage window that isn't a timestamp, or an invalid X-Prospero-On-Behalf-Of header |
| 401 | unauthorized | Missing, invalid or expired credential (with WWW-Authenticate: Bearer realm="prospero") |
| 403 | forbidden | Scope too low, or a cross-origin mutation made with a session cookie |
| 404 | not_found | Unknown agent, workspace or automation |
| 405 | method_not_allowed | The active fleet backend has no workspace admin plane wired, or automations have no config store |
| 409 | invalid_state, conflict | For example, registering a workspace name that already exists |
| 502 | protocol | caliband answered with something unexpected |
| 503 | unreachable | caliband (or the fleet backend) could not be reached |
| 500 | internal | Store or I/O failure |
Routes
| Method | Path | Scope | Purpose |
|---|---|---|---|
| GET | / , /assets/{*path} | open | The dashboard |
| GET | /healthz | open | Liveness: always 200 ok while the process is up |
| GET | /readyz | open | Readiness: 200 when the event store is writable, else 503 |
| GET / POST / DELETE | /api/session | open | Dashboard sign-in, whoami, sign-out |
| GET | /api/openapi.json | open | This API as an OpenAPI 3.1 document |
| GET | /api/capabilities | read | What the active backend supports |
| GET | /api/metrics | read | Operational counters |
| GET | /api/fleet | read | Fleet snapshot: every workspace and its agents |
| GET | /api/fleet/stream?from=N|now | read | Every stream's events, replay then tail (SSE) |
| POST/GET | /mcp | operate | Drive the fleet over the Model Context Protocol |
| GET | /api/usage | read | Cost, turns and outcomes per workspace per day |
| GET | /api/workspaces | read | Workspaces with health, sources, agent count and config |
| POST | /api/workspaces | admin | Register a workspace |
| PUT | /api/workspaces/{name}/config | admin | Replace a workspace's configuration |
| DELETE | /api/workspaces/{name} | admin | Unregister a workspace |
| GET | /api/workspaces/{workspace}/agents | read | Agents under one workspace |
| POST | /api/workspaces/{workspace}/agents | operate | Spawn an agent |
| GET | /api/agents/{id} | read | One agent's current state |
| DELETE | /api/agents/{id} | operate | Remove an agent |
| GET | /api/agents/{id}/events?from=N | read | Durable event history (JSON array) |
| GET | /api/agents/{id}/stream?from=N | read | Replay history, then tail live (SSE) |
| POST | /api/agents/{id}/kill | operate | Kill a running agent (202) |
| POST | /api/agents/{id}/respawn | operate | Replace an agent with a fresh one using the same spec |
| POST | /api/agents/{id}/input | operate | Send a user message to an interactive agent (202) |
| POST | /api/agents/{id}/end-input | operate | Signal end-of-input to an interactive agent (202) |
| GET | /api/automations | read | Every automation. Never includes a webhook signing key |
| POST | /api/automations | admin | Create an automation (201). A webhook's signing key is returned once, here |
| DELETE | /api/automations/{id} | admin | Delete an automation and its run history |
| PUT | /api/automations/{id}/enabled | admin | Enable or disable an automation without deleting it |
| GET | /api/automations/{id}/runs?limit=N | read | Recent runs, newest first (default 50) |
| POST | /api/automations/{id}/run | operate | Fire an automation now, whatever its trigger |
| POST | /api/automations/{id}/trigger | open (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:
| Field | Backend | Meaning |
|---|---|---|
provider | local | Provider id, exported to caliband as CALIBAN_PROVIDER |
base_url | local | Provider base URL, for example ANTHROPIC_BASE_URL or OPENAI_BASE_URL |
api_key_from_env | local | Name of an env var in prosperod's environment whose value becomes the provider's API key |
env | local | Raw KEY: value overrides. Stored and returned verbatim, so never put secrets here |
display_name | k8s | Dashboard label |
sources | k8s | [{name, repo, ref?, path}] git checkouts |
providers | k8s | [{name, kind, base_url?, model?, credentials_ref?: {secret_name, key}}] |
default_provider | k8s | Provider bound when a spawn names none |
isolation | k8s | {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.
| Tool | Does |
|---|---|
prospero_list_workspaces | Workspaces, health, agent counts |
prospero_list_agents | Every agent with status and workspace |
prospero_spawn_agent | Launch an agent (workspace, prompt, optional label, model, interactive, timeout_secs) |
prospero_agent_status | One agent's current state |
prospero_agent_events | Recorded events from from onward, capped (100 default, 500 max) with truncated + next_from |
prospero_send_input / prospero_end_input | Steer an interactive agent |
prospero_kill_agent / prospero_respawn_agent | Stop 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=nowskips 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 overdays.days: window length counted back fromuntil. 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.
kind | Fields | Meaning |
|---|---|---|
agent_spawned | — | Prospero asked for this agent |
agent_discovered | — | A poll found an agent Prospero had not seen |
agent_init | model, tools, session_id | The agent's run started |
status_changed | from, to | Lifecycle transition (spawning, running, idle, killed, done, failed, crashed) |
output | stream (stdout or thinking), chunk | Streamed text |
tool_started | id, name, input | A tool call began |
tool_finished | id, 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_finished | outcome, cost_usd, turns | Terminal accounting |
deadline_set | deadline | The 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_out | deadline | The 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_health | state | A workspace's caliband became healthy or unreachable |
store_persist_failed | lost_seq, detail | Event 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"
Securing the API
prosperod authenticates requests with named API tokens (ADR-0010).
Scopes
| Scope | Allows |
|---|---|
read | every GET: fleet, usage, workspaces, agent events and streams, metrics, automations and their runs |
operate | read + spawn, kill, respawn, input, end-input, remove agent; firing an automation by hand; the whole MCP endpoint |
admin | operate + add/remove workspace, set workspace config, create/delete/enable/disable an automation, and spawn with permission_posture: "unattended" |
/healthz, /readyz, the dashboard shell, /api/session and
/api/openapi.json are always open. So is an automation's
webhook trigger — its HMAC signature is the credential
instead of a token.
Creating tokens
prospero token new ariel --scope operate
prints the token once and a line for the tokens file. prosperod stores only
the SHA-256 hash. Start it with --api-tokens-file / PROSPERO_API_TOKENS_FILE.
Rules at startup
- Tokens configured ⇒ every request needs one, loopback included.
- No tokens and a non-loopback
--addr⇒ prosperod exits unless--insecure-no-auth. - Clustered (
PROSPERO_DATABASE_URL) with tokens ⇒--session-key-fileis required (openssl rand -base64 48 > session.key) so replicas share cookie signing.
Clients
- CLI:
PROSPERO_TOKEN/--token, orPROSPERO_TOKEN_FILE/--token-file;prospero whoami. - Dashboard: sign in with a token; controls above your scope are hidden.
- Scripts:
Authorization: Bearer pspo_….
Revoking
Delete the token's line and restart (in Kubernetes: update the Secret; Argo rolls the pods). Its sessions stop working immediately after the restart. Streams that were already open keep flowing until they reconnect.
Audit
Every mutation logs target=prospero_audit actor=<token name>. On the local
fleet, AgentSpawned and AgentGone events from spawn and remove also carry
actor.
Acting for someone else
A client that serves many people — a chat bridge, an MCP front end, a bot —
holds one token, so actor alone cannot tell one person's agents from
another's. Such a client may name the person it is acting for:
X-Prospero-On-Behalf-Of: discord:U123
The value is recorded on the events that request emits, as on_behalf_of,
beside the token's own actor. Both appear in the audit line too:
target=prospero_audit actor=ariel asserted_on_behalf_of=discord:U123
Prosperod does not verify this value. It cannot: the credential it
authenticated is the token, and it has no way to check that the person named
really asked for anything. The header is the client's assertion, and it is
stored as one. The token remains the authenticated identity and is always
recorded, so a false claim is still attributable to the credential that made
it — which is why the header needs no extra permission, and why you should
trust on_behalf_of exactly as far as you trust the token beside it.
It applies to any request, not just spawns, so kills and respawns carry it too.
Send nothing and behaviour is unchanged; a daemon predating this simply ignores
the header. Values are at most 128 characters and may not contain control
characters — a malformed one is a 400 rather than a silent drop, so a client
never believes it recorded an attribution it did not.
- CLI:
prospero --on-behalf-of <who> spawn …
Container image
ghcr.io/caliban-ai/prospero runs the prosperod control plane (REST, SSE and
the embedded dashboard) on port 7878. The image:
- is built for
linux/amd64andlinux/arm64, with thek8sfeature compiled in; - binds
0.0.0.0; - defaults to standalone mode (sqlite under
/data); - defaults to
--no-autostart, because the image contains no caliband.
Run: standalone
docker run --rm -p 7878:7878 -v prospero-data:/data \
ghcr.io/caliban-ai/prospero
# dashboard at http://localhost:7878/ , health at /healthz , /readyz
Run: clustered (external Postgres)
docker run --rm -p 7878:7878 \
-e PROSPERO_DATABASE_URL='postgres://user:pass@host:5432/prospero' \
-e PROSPERO_REPLICA_ID="$(hostname)" \
ghcr.io/caliban-ai/prospero --no-autostart
Both examples need authentication configured (or explicitly disabled) before prosperod will start; see Authentication.
Environment
| Var | Purpose | Image default |
|---|---|---|
PROSPERO_ADDR | Bind address | 0.0.0.0:7878 |
PROSPERO_DATA_DIR | sqlite directory (standalone) | /data |
PROSPERO_DATABASE_URL | When set, run clustered (Postgres) | unset (standalone) |
PROSPERO_REPLICA_ID | Lease identity; must be unique per replica | HOSTNAME |
PROSPERO_HOST | Fleet identity | local |
PROSPERO_FLEET | Fleet backend: local or k8s | local |
RUST_LOG | Log filter | info |
PROSPERO_API_TOKENS_FILE | Tokens file (<name> <scope> sha256:<hex>); when set, every non-probe request needs a token | unset |
PROSPERO_SESSION_KEY_FILE | Dashboard session key, at least 32 bytes; required when clustered with tokens | unset (random key per process) |
PROSPERO_INSECURE_NO_AUTH | 1 serves without auth on a non-loopback bind, with a warning logged every minute | unset |
PROSPERO_COOKIE_SECURE | 1 always marks the session cookie Secure | unset (Secure only when X-Forwarded-Proto: https) |
The schema is created in-process on boot; there is no migration step. prosperod handles SIGTERM for graceful shutdown. For every flag, see the guide's "Configuring prosperod" page.
Authentication
Because the image binds 0.0.0.0, prosperod refuses to start unless you
either mount a tokens file or pass PROSPERO_INSECURE_NO_AUTH=1:
prospero token new admin --scope admin # prints the token once + a tokens-file line
docker run --rm -p 7878:7878 -v prospero-data:/data \
-v "$PWD/tokens:/etc/prospero/tokens:ro" \
-e PROSPERO_API_TOKENS_FILE=/etc/prospero/tokens \
ghcr.io/caliban-ai/prospero
/healthz and /readyz stay open for probes. See ADR-0010 and the guide's
"Securing the API" page.
Fleet backends
--fleet-backend/PROSPERO_FLEET selects which FleetProvider drives the
fleet (local by default):
| Value | Backend | Status |
|---|---|---|
local (default) | LocalFleet: caliband over Unix sockets | Fully served. |
k8s | K8sFleet: CalibanTask CRs plus a network session plane (ADR 0008) | Served through the FleetProvider/FleetAdmin seams (prospero #76). Requires a build with --features k8s. |
PROSPERO_FLEET=k8s serves the dashboard and API against a cluster of
CalibanTask agents (create, observe, kill, stream). The API's handlers go
through the backend-agnostic FleetProvider for control, snapshots, readiness
and metrics. They read history and SSE from the shared event store and bus, so
both backends serve the same request path (#76).
Workspace register, config and remove also work under k8s (#142). They write
Workspace custom resources that the operator reconciles, which has these
consequences:
POST /api/workspacesandPUT /api/workspaces/{name}/configanswer202 Acceptedrather than201/204.- A workspace's sources and named providers come from its config;
rootis ignored. GET /api/capabilitiesreports"async_workspace_ops": true.- A spawn may pick a named provider with
provider_ref; otherwise the workspace's default provider is used.
PROSPERO_K8S_NAMESPACE (default default) selects the namespace for
CalibanTask and Workspace resources. The kube API-server connection defaults
to the in-cluster service account, then the ambient kubeconfig; pass
--kubeconfig <path> (or set KUBECONFIG) to use a specific kubeconfig file.
PROSPERO_INCLUDE_THINKING=1 records model reasoning as thinking output
events, which the dashboard shows as a collapsible segment. It is off by default
because of the volume and privacy cost.
Automations are stored in the shared config store, and the per-tick claim in that
store is what keeps two replicas from firing the same schedule. There is no
sqlite config store under k8s, so automations here need
PROSPERO_DATABASE_URL; without it the automation routes answer
405 method_not_allowed rather than firing once per pod.
Reporting agent lifecycle back to the operator
The operator stays infrastructure-only and never becomes a caliband client, so it
cannot tell "agent running" from "agent finished, pod still up". prosperod closes
that loop: after each poll in which a pod's caliband answers, it server-side
applies one AgentsSettled condition to the task's status under field manager
prospero — True/Succeeded when every agent finished, True/Failed if any
ended failed or crashed, otherwise False/AgentsActive (an idle interactive
task is deliberately not settled). caliban-operator turns that into a terminal
Completed or Failed phase, so a finished task leaves Running.
This needs three things in the cluster:
- RBAC
getandpatchoncalibantasks/status. The prospero Helm chart grants it with thek8sfleet backend selected. Without it prosperod logs a warning on every poll and carries on — reporting is best-effort and never takes the observation loop down. - caliban-operator ≥ v0.5.0, which is the version that acts on the condition.
- a
CalibanTaskCRD whose condition schema accepts it (caliban-crds ≥ v0.2.4, for the conditions map-list).
The apply carries only that one condition and never forces, so phase,
calibandEndpoint, sandboxRef, resolvedWorkspace and the operator's own
conditions stay owned by caliban-operator. Tasks whose caliband did not answer
this pass are skipped rather than flipped on a transient error, and the write
runs under the observer lease so clustered replicas don't race.
Under k8s, prosperod serves K8sFleet over the shared event store and bus. It
runs no local FleetManager and no poll loop; those are local-only
machinery. When clustered, per-agent session-plane leases keep two replicas from
streaming the same agent. Both backends use the same storage topology (sqlite
standalone, Postgres clustered) for history and SSE.
k8s session-plane security
The per-agent session plane is prosperod's link to each caliband pod: the live
output stream and input. It supports TLS and a bearer token, following caliban's
ADR 0051. Both are off by default (plaintext), and each turns on only when
its file is provided, typically from a mounted Kubernetes Secret volume:
| Flag | Env | Purpose |
|---|---|---|
--k8s-caliband-ca-file | PROSPERO_K8S_CALIBAND_CA_FILE | PEM CA bundle trusting caliband's serving cert. When set, dials use TLS. |
--k8s-caliband-token-file | PROSPERO_K8S_CALIBAND_TOKEN_FILE | File holding the shared bearer token (contents trimmed). When set, dials present it. |
--k8s-caliband-server-name | PROSPERO_K8S_CALIBAND_SERVER_NAME | SNI / cert-validation name (default caliband). |
Configuring a token without the CA file is a fatal startup error, because the token would otherwise travel in cleartext. An unreadable or malformed CA or token file is also fatal; there is no silent fall-back to plaintext. Secret rotation takes effect on the next pod restart, since the files are read once at startup.
If prosperod wasn't built with the k8s cargo feature (which forwards to
prospero-core/k8s), selecting k8s fails at startup with a message pointing
at cargo build -p prospero-daemon --features k8s.
Changelog
All notable changes to prospero are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. While the project is pre-1.0, the minor version is bumped for new features and the patch version for fixes.
Unreleased
Fixed
- The published guide no longer ships dead links. An ADR is written to live in
docs/adr/, whereREADME.mdand../superpowers/specs/<spec>.mdboth resolve; copied into the book they did not, becauseREADME.mdandtemplate.mdare deliberately not chapters and nothing ingestsdocs/superpowers/. mdBook rewrote both to pages it never built, so three links in ADR 0001 and theSource:link of seven ADRs (0002–0007 and 0010) served 404s.sync-adrs.shnow rewrites links that leave the book to absolute repository URLs, resolving each target from the ADR's original location rather than its copied one, and fails the build if a target does not exist — a link rewritten to a missing file is still a dead link. (#268)
Internal
-
The guide's links are checked after every docs build, by
docs/guide/check-links.sh. mdBook validates no links whatsoever: it rewrote every one of the dead links above and emitted zero warnings, so "the guide builds clean" was never evidence that it was navigable. The check resolves relative hrefs against the built output and absolute links back into this repository against the working tree — the latter because the rewrite above converts checked relative links into unchecked absolute ones, and a check that skipped them would bless exactly the mistake the rewrite can make. Third-party URLs are not fetched, so the docs build does not depend on anyone else's uptime. (#268) -
The docs site is rebuilt when
CHANGELOG.mdchanges.sync-changelog.shingests it into the guide, butdocs.ymlwas not triggered by it, so a changelog-only commit rebuilt nothing and the published changelog silently fell behind the repository — entries appeared only when some later commit happened to touch a path that did trigger a build. The same guard that coversci.yml's path filter now also fails the build when a path async-*.shscript reads is missing from either ofdocs.yml's triggers, since the next ingest would otherwise repeat this. (#270) -
CI no longer skips the cargo gate on a docs change that is compiled into a test.
ci.ymlskips every cargo step when a diff touches onlydocs/,*.mdorLICENSE, butcrates/api/tests/guide.rsinclude_str!sdocs/guide/src/api.md— so an edit to the guide's routes table was the single change most likely to fail the route-coverage test added in #256, and the one change CI would not run it for. The workflow now declares those paths inTEST_INPUT_DOCSand treats them as code; a genuinely docs-only change still skips the gate. The declaration is held to the source bycrates/api/tests/ci_filter.rs, which fails the build both when a new doc test input is missing from the list and when a stale entry no longer matches, so the filter cannot quietly go out of date. (#264) -
The Rust toolchain is pinned in
rust-toolchain.toml(1.99.0), so a new stable release can no longer break a build whose code never changed — which is what happened to v0.9.0. CI still installs its toolchain withdtolnay/rust-toolchain@stable; rustup honours this file when cargo runs, so the version lives in exactly one place and upgrading it is a reviewable commit. The file also lists thewasm32-unknown-unknowntarget, because thetargets:input inci.ymlapplies to the toolchain the action installs rather than the one this file selects. (#261) -
caliban-contractpinned to=0.15.0, matching caliban 0.15.0 and moving in lockstep with caliban-operator so the two downstream consumers of the contract do not disagree about its version (caliban-ai/caliban-operator#95). Nothing prospero compiles against changed:launch.rsis the only file that differs between the two contract versions, and prospero imports only fromcaliban_contract::wire. Note for anyone else downstream — the fieldCalibandLaunch.router_configkept its type but changed meaning, from inline config JSON to a filesystem path (caliban ADR 0060), so a caller passing JSON still compiles and silently has it read as a path. prospero never sets it.
0.9.0 - 2026-10-03
This release is about letting other programs drive and watch the fleet. One SSE
connection now carries every agent's events with a resumable cursor, /mcp
serves the fleet to agentic tools, and GET /api/openapi.json describes the
whole REST surface so a client can generate its types instead of hand-writing
them from prose. Spawns no longer have to be manual: an automation fires one on
a cron schedule or a signed webhook. In Kubernetes, a per-session permission
posture makes in-cluster agents usable at last, and when the operator refuses a
task prospero reports the reason instead of leaving an unexplained dead agent.
**Companion versions.** The permission posture needs caliban v0.14.0 (for
`SpawnSpec.permission_posture`) and caliban-operator v0.6.0 (which admits or
refuses it and supplies the refusal reason). Automations need an event and
config store shared by every replica, so they are enabled for the local backend
and, under Kubernetes, only when `--database-url` is set.
Added
-
Fleet-wide event stream.
GET /api/fleet/streamcarries every stream's events on one connection, so watching a fleet no longer needs an SSE connection per agent — and a client can finally learn about an agent it has not seen yet. Each event's SSEid:is a fleet cursor: the store's durable insertion order, the only total order a fleet has (per-agentseqcannot order two agents, since both start at 1).?from=<cursor>resumes exclusively after it,?from=nowskips history, and an absentfromreplays from the start. Every event is read from the durable store rather than the in-process bus, so delivery is exactly-once across replicas and a slow client is never skipped past. Two newStoremethods (replay_fleet,latest_fleet_cursor) are held to identical semantics across JSONL, SQLite and Postgres by a shared conformance battery. Requiresread. The dashboard's 5-second poll is gone, replaced by the stream plus a 30-second reconcile so a droppedEventSourcecannot freeze the screen. (#219) (#247) -
The fleet over MCP.
/mcpserves the fleet over the Model Context Protocol (streamable HTTP,rmcp— the same crate caliban drives its own adapter with, so both speak an identical protocol version to a client). Nine tools: list workspaces and agents, spawn, status, events, send and end input, kill, respawn. It is a thin adapter over the sameFleetProviderseam the REST handlers already use, so both the local and Kubernetes backends work through it and a tool cannot do what the HTTP API cannot. Two choices follow from the consumer being a model rather than a person: history is capped (100 default, 500 max) and returnstruncated+next_from, and a bad agent id is a tool error naming the id rather than a protocol error, so the model can correct itself instead of the session failing. Requiresoperate; workspace administration is deliberately not exposed. (#218) (#249) -
Automations — scheduled and webhook-triggered spawns. An automation is a stored spawn template plus what fires it: a 5-field UTC cron schedule, or a signed inbound webhook whose payload fills the template's
{{ dotted.path }}placeholders. Every firing is recorded, successful or not, in a bounded per-automation history. Exactly-once across replicas is a compare-and-set on the claimed tick rather than the lifecycle lease: a lease makes a double fire unlikely but cannot rule it out, because ownership can change hands between checking and spawning. The webhook signing key is returned once at creation and lives only on core's own stored type, so no response, listing or dashboard view can carry it; signatures are verified in constant time over the raw request bytes, and an absent or malformed header is a rejection. (#220) (#250) -
An OpenAPI 3.1 document at
GET /api/openapi.json. Clients — ariel first — had to hand-write every request and response type from the prose guide. The document is open like the guide it mirrors: it describes the API's shape, carries no fleet data, and a client needs it before it has a credential. Schemas come from the sameprospero-typesstructs the handlers serialize, via schemars, behind an off-by-defaultopenapifeature so the wasm dashboard — which shares those types — does not carry a schema generator it never calls. Requests and responses are generated under their own contracts, because a type is described differently when it is read than when it is written: a serde default makes a field optional to a reader and says nothing to a writer, so one schema per type would misdescribe one direction. Each operation records its required scope inx-prospero-scope. The spec cannot quietly fall behind the router — a test readslib.rsfor the paths and verbs it registers, so a new route, a new verb, or a documented route that is not served all fail the build. (#222) (#253) -
Per-session permission posture. In-cluster agents were unusable: no human is attached to a pod, so caliban's non-interactive gate denied every Bash/Write/Edit and the refusal reached nobody.
permission_posture(supervised|unattended, defaulting tosupervisedeverywhere, fail-closed) is now carried on the spawn body, the CLI (--permission-posture) and theCalibanTask.unattendedrequires anadminscope — a per-field rule the route scope table cannot express — and each grant is audited with the token name. Under Kubernetes what prospero sends caliband is the posture the operator admitted (status.permissionPosture), not the one requested, so an operator predating the field cannot be used to bypass the Workspace'sagentPolicy. Every agent reports the posture it runs under, and unattended agents carry abypasstag in the dashboard. Also wiresspec.isolation.worktreesthrough toSpawnSpec.isolation_worktree. (#238, #226) (#240) -
Per-spawn wall-clock limit.
timeout_secson a spawn (prospero spawn --timeout <SECONDS>) caps an agent's wall-clock life. The deadline is a log entry, not a timer — a timer dies with the replica that set it — so prospero writesDeadlineSetto the shared event log, and a replica that never spawned the agent reads it back the first time it polls. Enforcement runs under the lifecycle lease so one replica acts on an expiry rather than all of them racing to kill the same agent, and anAgentTimedOutevent is emitted before the kill, so history says why the agent stopped instead of leaving a barekilledthat reads like an operator did it./api/usagecounts it astimed_outalongsidekilled, and the dashboard gains the facet. Deliberately noAgentStatus::TimedOut: that enum is matched exhaustively by clients and mirrors caliban's own lifecycle vocabulary, which has no such state, so the distinct outcome is the event. (#221) (#248) -
Attribution to a person, not just a token.
FleetEvent.actoris the token name, which is right for attributing a request to a credential and useless for a client acting on behalf of other people — ariel holds oneoperatetoken and serves a whole Discord, so every agent it spawned readactor: "ariel"whoever asked for it. A request may now carryX-Prospero-On-Behalf-Of, and the events it emits record it ason_behalf_ofbeside the unchangedactor. A header rather than a spawn-body field, so it covers kill and respawn too and an older daemon ignores it for free. The value is the client's assertion, not a fact prosperod checked: the token remains the authenticated identity and is always recorded, so a false claim stays attributable to the credential that made it, and the audit line names itasserted_on_behalf_of. Validated rather than sanitized, and bounded — it reaches the event log and the structured logs, where a control character could forge what reads as a separate record — and a malformed value is a 400 rather than a silent drop. Both SQL stores gain the column. (#251) (#252) -
prospero usage.GET /api/usagewas rendered only by the dashboard. The CLI now prints the same report as a table — one row per workspace, a total when there is more than one, the window stated up front — or, with--json, the server's own report.prospero usage [--since <7d|2w|YYYY-MM-DD|RFC-3339>] [--workspace <name>] [--json]7d/2ware resolved on the server's clock, as the dashboard does, so a drifted client clock cannot clip the window; a date is its UTC midnight and a timestamp is converted to UTC. An hour-level window such as24his refused with a reason, because usage is bucketed per UTC day and an hour would promise a precision the report lacks.--workspacefilters client-side on the raw JSON, so--jsonkeeps fields a newer server adds and both output modes show the same rows. (#223) (#254)
Fixed
-
GET /api/usagevalidates and normalizes its window. Three bugs, each reproduced by a test that failed first. A largedayspanicked the handler —?days=200000000was enough, chrono overflowing on the subtraction rather than insideDuration::daysas the ticket guessed.sinceanduntilwere never parsed, so?since=yesterdayreturned 200 and a report for whatever window that string happens to sort between, because the store compares timestamps lexically on a TEXT column. And for that same reason the spelling of a correct bound mattered: events are stamped…00:00:00.5+00:00, and aZbound sorts after the.of a fractional second, so an event half a second into the window fell out of it — the handler already normalized its own defaults but never client-supplied bounds. Both bounds are now parsed (400bad_requeston failure) and re-rendered, anddaysis checked against a shared cap. (#255) (#257) -
One
Drainedagent no longer drops every agent on its caliband.wire.rshand-mirrored caliband's control-plane types, and the mirror had already drifted into a live bug: caliban'sAgentStatusgainedDrainedthat the mirror never learned, and with no catch-all a single drained agent failed deserialization of the entireListedreply, dropping every agent on that caliband from the poll. Those types are now re-exported from caliban's publishedcaliban-contractcrate (pinned=0.14.0) — serde-only, so not the wide coupling ADR-0003 rejected, and exactly what its own "revisit if" clause asked for once the mirror became a drift source.Drainedmaps toDone: terminal, not a failure. (#239) (#245) -
The operator's failure reason reaches the agent. caliban-operator v0.6.0 refuses an
unattendedtask whose Workspace does not allow it — phaseFailed,Ready=False, reasonPostureNotPermitted— and creates no pod, so there is no sandbox, no caliband and no agent-side error anywhere: the operator's condition is the only account of what happened. prospero mapped the phase tofailedand dropped the rest, leaving an unexplained dead agent and someone waiting for a sandbox that was never coming.Agentgains optionalreasonanddetail, omitted when unset, filled from the operator'sReadycondition and only when it is notTrue. Local agents never carry them, since caliband reports their state directly with no admission step above it. (#241) (#246) -
Tool results show in the dashboard inspector. caliban has carried each tool call's result on
ToolCallEndsince caliban#391, but prospero's normalizer read onlytool_use_idandis_error, so the inspector still said the result "needs a caliban-side change".EventKind::ToolFinishedgainsresultandtruncated, both defaulted and skipped when empty, so events stored before this change still deserialize. The normalizer takes caliban's bounded display preview and applies its own 8,192-character cap, matching caliban's, so one call cannot put an unbounded body into the event stores or SSE. (#236) (#237)
Documentation
-
ADR-0011 — ACP is a second drive protocol, not a second lifecycle (status: proposed). Records an
AgentDriverseam for an agent's data path with caliband keeping every lifecycle, the protocol chosen per spawn and defaulting to NDJSON, and the decision not to make prosperod a process supervisor. Amends ADR-0003. (#217) (#244) -
Automations documented, across both the API and CLI guides: the create body and template defaults, cron semantics, webhook signing with a worked
opensslexample, placeholder rules, and run history. The guide's routes table now carries the same build-breaking guard as the OpenAPI document — a test checks it against the paths and verbslib.rsregisters — so a route can no longer ship undocumented. (#256) (#258)
Internal
async-traitbumped to 0.1.92. Up to 0.1.89 the macro put#[must_use]on the method it desugars anasync fninto, and since that method returns a future — already#[must_use]— clippy 1.99 reportsdouble_must_useon macro-generated code, failing the lint gate for all seven#[async_trait]traits inprospero-core. Nothing in the repo changed to cause it: CI resolvesstableat run time, so the build broke when stable moved from 1.97 to 1.99. The upstream bump is the fix rather than anallow.
0.8.1 - 2026-09-16
Fixed
- The
AgentsSettledcondition is written in a real cluster. v0.8.0's condition carriedlastTransitionTime, which theCalibanTaskCRD's condition schema doesn't declare, so server-side apply rejected every write with a 500 ("field not declared in schema"). No condition was ever written, a finished task never reachedCompleted, and prosperod logged the failure on every poll. prospero now sends onlytype,statusandreason, matching caliban-operator'sCondition, and a test pins the apply body to those fields. (#234)
0.8.0 - 2026-09-16
prosperod now authenticates its API, and in Kubernetes it reports agent
lifecycle back to the operator. Named API tokens with one of three scopes guard
every non-probe route; the dashboard signs in with a token and holds a signed
session cookie; and the CLI carries a token. Separately, prosperod writes an
AgentsSettled condition to each CalibanTask's status, which caliban-operator
v0.5.0 turns into a terminal Completed or Failed phase, so a finished task
finally leaves Running.
**Breaking for deployments:** with no tokens configured, prosperod now refuses a
non-loopback `--addr` (such as `0.0.0.0` in a container) unless
`--insecure-no-auth` / `PROSPERO_INSECURE_NO_AUTH=1` is set. Configure
`--api-tokens-file` / `PROSPERO_API_TOKENS_FILE` before upgrading, or opt out
explicitly. See the "Securing the API" guide page.
Added
-
Breaking: API authentication and authorization (ADR-0010). Named tokens (
pspo_…) with scoperead,operateoradmin, stored as SHA-256 hashes in a tokens file; a route-scope table that fails closed toadmin; JSON 401/403; and oneprospero_auditlog line per mutation. With tokens configured, every request needs one, loopback included./healthz,/readyz, the dashboard shell and/api/sessionstay open. The dashboard signs in with a token for a 12-hour HMAC-signedHttpOnly; SameSite=Strictcookie bound to the token's hash, and cookie-authenticated mutations must be same-origin. Clustered prosperod with tokens needs--session-key-fileso replicas share cookie signing. The token name is recorded asactoron local-fleet spawn and remove events and persisted in every store (nullable column migration). CLI:--token/PROSPERO_TOKEN,--token-file/PROSPERO_TOKEN_FILE,prospero token new <name> --scope <scope>andprospero whoami. (#2) (#227) -
AgentsSettledcondition onCalibanTaskstatus. After each poll in which a pod's caliband answers, prosperod server-side applies one condition under field managerprospero:True/Succeededwhen every agent finished,True/Failedif any ended failed or crashed, otherwiseFalse/AgentsActive(an idle interactive task stays unsettled). The apply carries only that condition, never forces, skips tasks whose caliband didn't answer, and runs under the observer lease so clustered replicas don't race. Needs RBACpatchoncalibantasks/status(caliban-ai/helm-charts prospero chart 0.2.9); without it prosperod logs a warning per poll and carries on. (#228) (#232) -
View model thinking in the dashboard. With
PROSPERO_INCLUDE_THINKINGset, reasoning content is kept through normalization and shown as a collapsible "Thinking" segment on the agent timeline, separate from output. Off by default. (#212) (#216)
Fixed
- A re-spawned k8s agent that reuses a task name is attached again. A fast
delete and recreate of a same-named
CalibanTaskleft the predecessor's terminal mark in place, so prospero never attached to the healthy new agent until prosperod restarted. The poll loop now tracks each CR's uid and clears the mark when a known name returns with a new uid. (#213) (#214)
Documentation
- User guide pages, and the README and container docs brought current. (#229)
- Competitor evaluation: re-verified the OpenClaw parity matrix and added OpenHands, Coder, GitHub Agent HQ and Vibe Kanban. (#215)
0.7.0 - 2026-09-13
Follows caliban's removal of its bespoke ollama provider (caliban ADR 0056).
prospero fills the caliban worker's SpawnSpec.provider from a repo's stored
config, so a repo pinned to ollama now spawns a worker that fails at startup —
this release migrates prospero off Ollama and onto the OpenAI-compatible path.
Changed
- Ollama removed; local inference via
openai+base_url(#207): the dropdown, defaults, fixtures, and the dedicatedollamaenv-projection arm are gone.validate_provider_envnow treats a provider with an overriddenbase_urlas keyless — mirroring caliban's keyless local-endpoint change — so a migrated local config (openai+base_url, no key) keeps the keyless UX thatollamahad, instead of being rejected for a missingOPENAI_API_KEY. (#208) - Reworded the remaining rationale comments off the removed provider's name; the history lives in caliban ADR 0056 (#209). (#210)
A repo or workspace still configured with `provider: "ollama"` will spawn a
caliban worker that fails loudly at startup. Migrate it to `openai` with a
`base_url` pointing at a local `/v1` endpoint.
0.6.0 - 2026-09-09
Finishes what v0.5.0 started. That release made the Rust → WASM dashboard the
default and left the old vanilla-JS page at /v1 as a landing spot in case v2
regressed. It didn't, so both the page and the version suffix that existed to
distinguish the two are gone: there is one dashboard, served at /, and its
files live under /assets/.
The rest is the k8s fleet and its test coverage getting more honest. A pod agent
that dies at spawn is a stable state, and prospero now treats it as one instead
of re-dialing it every two seconds for as long as the process lives — a loop
measured running for twenty-four days against a single stuck task. Two long-open
gaps in coverage close alongside it: cross-stream delivery on the clustered bus,
which had been #[ignore]d as too flaky to gate on, and streaming under
fan-out, which had no coverage at all.
Removed
-
Breaking: the v1 dashboard. The hand-written vanilla-JS page that served
/before v0.5.0, deprecated to/v1in #191, is deleted along with its embedded assets and routes./v1and/v1/app.jsnow 404 — deliberately no redirect and no fallback, so a dead surface does not linger in bookmarks and access logs. Removing it also drops ~200KB of embedded assets from every binary and the known defects v2 was built to fix, most visibly #106's permanently-"running" tool calls. (#197) -
Breaking: the
/v2alias, and the version suffix generally. With only one dashboard left there is nothing to disambiguate, so the document is served at/and its files move from/v2/...to/assets/.... A prefix rather than the bare root, so the bundle cannot shadow the API namespace.GET /v2now 404s. Internally the same rename:crates/api/dashboard-v2/iscrates/api/dashboard/, the handler module isdashboard, and the CI job is "dashboard wasm build". (#204)
Added
-
DistributedBus::subscribe_all_ready— an unfiltered subscription whoseLISTENis established before it returns, rather than lazily on the stream's first poll. The lazy form cannot tell a caller when it is safe to publish, and a doorbell rung in that gap is simply not heard. Connection andLISTENfailures now surface as an error instead of silently ending the stream. (#132) -
Load and soak coverage for streaming under pressure. Forty concurrent attach tasks must each stream to completion carrying their own output and monotonic sequence numbers, so a crossed or silently-unattached stream fails loudly rather than going unnoticed. The in-process bus gains bounded-buffer coverage: a slow subscriber is told exactly how much it lost and keeps working, a subscriber that keeps up never lags, sixty-four subscribers lag independently rather than sharing a cursor, and dropping one mid-pressure leaves the rest untouched. Asserted on observable state rather than elapsed time, so they run in the gate instead of becoming the next ignored test. (#8)
Fixed
-
A terminal pod agent is resolved once, not on every poll. Rejecting a terminal record (#168) removed the reconnect budget that had been incidentally throttling the retry, so the ~2s watch loop re-resolved the same dead agent forever — one warning per poll, indefinitely, for a state that cannot resolve on its own. Observed running for 24 days against a single stuck task. The session plane now remembers terminal agent ids, skips them, and logs the cause once per transition. An explicit stop or restart, or the CR going away, clears the mark so a respawn is re-checked. (#170)
-
Cross-stream
subscribe_alldelivery is covered in CI again. The test was#[ignore]d because the lazy subscription gave it no way to know when it was safe to ring the doorbell, so it re-rang up to 440 times over 44s and still starved to zero deliveries on a contended runner. It now establishesLISTENfirst and asserts on delivery rather than on patience — about a second, including under the coverage instrumentation the flake was worst under. (#132)
Security
anyhowbumped to 1.0.104, clearing RUSTSEC-2026-0190 (unsoundness inError::downcast_mut()). Lockfile only; nothing here callsdowncast_mut, but a release should not carry a known-unsound dependency. (#205)
0.5.0 - 2026-08-22
Replaces the dashboard. The hand-written vanilla-JS page is superseded by a
Rust → WASM single-page app that shares the server's own types, and it now
serves /; the old page remains at /v1, deprecated. Everything an operator
previously needed v1 for — launching, killing, replying to an interactive
agent, editing a workspace's config, watching a live stream — is in v2, along
with things v1 never had: a per-agent timeline with a tool-call inspector, and
fleet-wide spend, turns, and outcome charts over a selectable window.
The other half of this release is the k8s fleet telling the truth about what it
ran. Terminal outcomes were structurally invisible under PROSPERO_FLEET=k8s:
the watch loop derived agent status from the CalibanTask phase, and the
operator never advances that past Running, so the new outcome charts read a
permanent zero — including for runs that had demonstrably failed. Outcomes are
now taken from the pod, recorded once per agent, and survive a restart.
Upgrade note. GET / now serves dashboard v2 rather than v1, and
GET /app.js is gone — v1's script moved to /v1/app.js along with the page.
/v2 remains a permanent alias for the new dashboard, so existing links to it
keep working.
Added
- Dashboard v2 — a Rust → WASM single-page app, and now the dashboard. A
Dioxus SPA in
crates/dashboard, compiled towasm32-unknown-unknownand embedded inprosperod, replacing the hand-written vanilla-JS page. The read model and the control-plane DTOs are the same Rust types the server uses, so the client cannot drift from the API the way a hand-maintained JS copy did. The crate sits outside the cargo workspace on purpose (a wasm-only crate would break the host-target build and sink the coverage floor) and has its own CI job; its built bundle is committed andinclude_bytes!'d, so an ordinarycargo buildstill needs no wasm toolchain and one binary still ships the UI (#97). - Full operator control from the dashboard. Launch, kill, remove, respawn,
interactive input, end-input, and workspace removal — every action gated on
/api/capabilities, so the UI never offers an operation the active backend would answer with a 405 (#173). - Workspace registration and configuration from the dashboard, with two form
shapes chosen at runtime: the local single-provider/env form, and the k8s
Workspace-CR editor with sources, named providers, and Secret references, plus the reconciliation-status pill and a provider picker on launch. This also fixed a lossy read:GET /api/workspaceswas projecting the CR's source spec down and dropping the git remote and ref, so editing a k8s workspace meant retyping every remote from memory (#175). - Live agent stream viewer — replay history from the store, then tail over
SSE. A reconnect resumes at
last_seq + 1and anything at or below the high-water mark is dropped, which is the defence against the v1 reconnect storm that duplicated the timeline unboundedly (#105); a close afterAgentFinishedreads as "finished" rather than an error, so a healthy completed run does not sit there retrying (#178). - Per-agent timeline with a tool-call inspector. Tool calls pair start with
finish into a collapsible entry, consecutive output coalesces, and the opening
context and final accounting become a header and a summary. Pairing is on the
tool id and never the name — caliban's
ToolCallEndcarries the id but leaves the name empty, which is what left every tool stuck "running" in v1 (#106) (#179). GET /api/usage— cost, turns, and terminal outcomes aggregated per workspace per UTC day over a window (since/until, ordays). Computed by the store rather than by replaying the log, with identical semantics across the JSONL, SQLite, and Postgres backends, held there by a shared conformance suite (#180).- Fleet overview charts for spend, turns, and outcomes over a selectable
24h / 7d / 30d window. Outcomes are faceted rather than stacked: measured
against the real tokens, no ordering of a four-way stack is separable in light
mode, so one single-hue chart per outcome is the fix rather than a mitigation.
Hand-rolled SVG with native
<title>tooltips — the page is served underdefault-src 'none', so there is no script and nothing for the CSP to refuse (#181). - An explicit theme setting (System / Light / Dark), persisted in
localStorage, replacing "whateverprefers-color-schemesays". The explicit choice wins in both directions, and the theme is applied before first paint so there is no flash of the wrong one (#183).
Changed
- The control-plane DTOs moved to
prospero-typesand now carry bothSerializeandDeserialize. They previously lived inprospero-api, which pulls axum and tokio and so compiles for no wasm target, and each carried only the server's half of the contract — so a WASM client would have had to hand-duplicate all eight, reintroducing exactly the drift Rust/WASM was chosen to avoid. They are re-exported from their original paths and the serde output is unchanged, so this is additive for existing consumers (#172). - Dashboard v2 is now the default surface.
GET /serves the Dioxus/WASM dashboard;/v2stays mounted as a permanent alias, since the bundle's own asset URLs are absolute/v2/...and existing links point there. The scaffold deliberately parked v2 at/v2so/stayed untouched while epic #95 landed — that transition is complete (#191).
Deprecated
- The v1 dashboard has moved to
/v1and is deprecated. It renders a notice pointing at/, and its script now lives at/v1/app.js(GET /app.jsis gone). It is kept rather than removed so an operator hitting a v2 regression has somewhere to land, but it receives no further work and carries defects v2 was built to fix — most visibly #106, where a tool call whose finish frame has a blank name stays "running" forever. Removal is a follow-up once v2 has a release of real-world use (#191).
Fixed
-
Restarting prosperod no longer re-counts outcomes it already recorded. A fresh process starts with an empty view of the fleet and re-derives the terminal transition it had already written before the restart, so every outcome facet doubled on each restart — measured on a live cluster as 3 done / 3 failed becoming 6 / 6 with nothing having run. The watch loop now checks the durable log (which outlives the process) before recording an agent's outcome, so an agent that finished once is counted once (#196).
-
Terminal outcomes are now derived from the pod, not the CalibanTask phase. #190 made the watch loop persist the transitions it observed, but it observed
status.phase, and the operator never advances that pastRunning— CRs whose agents finished a day earlier still readRunning, so no terminal transition ever occurred and the outcome facets stayed at zero on a real cluster. The loop now applies the same pod-caliband overlaysnapshot()has always used, so the component that emits events and the one that renders them finally agree. Pod dials are bounded and concurrent, and an agent is consulted only until it is observed terminal (#194). -
An unreachable pod can no longer stall fleet observation. The status overlay dialled each pod sequentially with no deadline. A pod that black-holes its SYN (rather than refusing it) blocked until the OS connect timeout, which was survivable when only
GET /api/fleetdid this and is not now that the watch loop depends on it. Dials are concurrent and bounded; a miss retries on the next poll (#194). -
Terminal outcomes are now recorded under
PROSPERO_FLEET=k8s. The usage aggregate countsdone/failed/killed/crashedfrom persistedstatus_changedevents. The local arm has always emitted them, but the k8s watch loop computed the identical transition diff and only broadcast it in-memory — so on k8s every outcome facet in the dashboard read zero, including for agents that had demonstrably failed. The loop now persists the transition it observes, elected by a single-writer observer lease so replicas don't multiply the counts (#190). -
The dashboard's usage panel refreshes on its own. It fetched once per window selection and never again, so a finished agent's spend could sit invisible until the operator toggled the window by hand. It now refetches when the fleet poll observes an agent appear, change status, or disappear, and on a one-minute heartbeat — without re-running a 30-day store aggregate on every five-second poll (#190).
-
A k8s workspace's provider base URL survives an edit. The v2 editor had no base-URL input and
ProviderInfodid not carry the field, so reopening the editor showed a blank box and saving wrote it back — a routine model edit silently unpicked a self-hosted provider, after which agents died instantly with aProviderErroragainstlocalhost(#188). -
k8s-spawned agents get the resolved provider and model.
spawn_spec_from_tasksentprovider/modelasNoneon the premise that pod env would drive selection; the caliban worker selects fromSpawnSpecand nothing else, so every k8s agent fell back to caliban's default and died at preflight withANTHROPIC_API_KEY is not set. The operator already pins the resolved provider intostatus.resolvedWorkspace, so its kind and model are projected onto the spawn.ensure_pod_agentalso no longer attaches to an agent already in a terminal state — caliband keeps a failed agent in its registry with the endpoint it advertised, but the worker died before binding that port, turning a one-shot failure into a silent reconnect loop (#169). -
Re-submitting an identical prompt no longer claims a launch that didn't happen. Spawning is idempotent and the k8s
CalibanTaskname is derived from the spec, so an identical prompt resolves to the run already in flight.POST /api/workspaces/{name}/agentsnow reportscreated, and the dashboard says it attached to the existing run instead of "Launched" (#190).
0.4.0 - 2026-07-27
Makes the PROSPERO_FLEET=k8s fleet interactive. Since 0.3.0 a k8s agent
could be launched from the dashboard but never talked to: the reply box renders
only for an agent that is both interactive and idle, and under the k8s
backend neither could ever be true. Closing that took a field on the
authoritative CRD (caliban-operator#28) plus both halves of the round-trip here,
and turned up a second defect — 0.3.3's agent-id decoupling had quietly broken
reply delivery and the 0.3.2 status overlay. Local behavior is unchanged.
Deploy note: requires the caliban-crds chart at >= 0.2.1. Older CRDs
have no spec.task.interactive, so the API server prunes the field at admission
and the flag never reaches the pod.
Added
- Interactive agents under
PROSPERO_FLEET=k8s. The dashboard'sinteractive: truenow survives the CR round-trip:build_calibantaskwritesspec.task.interactive(the field caliban-operator#28 added to the authoritative CRD) andspawn_spec_from_taskreads it back into theSpawnSpecsent to the pod's caliband. Previously the flag was silently dropped at the CR boundary and the spawn spec hardcodedinteractive: false, so a k8s agent could never await input and the dashboard reply box (interactive && idle) could never appear. Requires thecaliban-crdschart at >= 0.2.1, or the API server prunes the field at admission (#163).
Fixed
- Operator replies and the interactive/idle overlay now target caliband's own
agent id. #159 decoupled the agent id caliband assigns from prospero's CR
name, but
send_inputstill attached by CR name (404, reply lost) andoverlay_pod_statusstill looked records up by id (silent miss, regressing #130's reply box).send_inputnow resolves the pod's agent viaensure_pod_agent, and the overlay matches each pod's record by endpoint rather than by id. The pre-existing tests used one string for both ids, so they passed while the real path was broken; the new tests use distinct ids (#163).
0.3.3 - 2026-07-19
Completes the PROSPERO_FLEET=k8s control plane so a spawned agent actually
runs: the previous two fixes let the caliband pod bind and decoupled spawn from
reconcile, but nothing ever started the agent inside the pod. Local behavior is
unchanged.
Fixed
- k8s agents now actually start (and stream). Under the k8s backend,
spawning created the
CalibanTaskCR and a caliband pod but never started the LLM run — caliband is a passive supervisor that begins an agent only onCtlRequest::Spawn, and prospero's session plane only attached, so the attach looped forever onagent not found. prospero now spawns the agent in the pod's caliband (list-or-spawn, one agent per pod) from theCalibanTaskprompt, then attaches — idempotent across poll cycles and replicas (ownership-lease-gated). Because caliband assigns the agent id itself, the attach id is decoupled from prospero's stream key (the CR name) so output still streams to the dashboard under the identity/streamexpects (#159) (#160).
0.3.2 - 2026-07-18
Two fixes to the PROSPERO_FLEET=k8s control plane found in live-cluster use:
spawning an agent no longer blocks the dashboard on the operator's reconcile,
and an agent's interactive reply box now appears under the k8s backend. Local
behavior is unchanged.
Fixed
- Spawning an agent no longer hangs the dashboard on reconcile. Under the
k8s backend,
K8sFleet::ensure_agentapplied theCalibanTaskCR and then synchronously polled (up to ~30s) forstatus.phase == "Running"before returning, coupling the HTTP response to the fullCR → operator reconcile → pod schedule → Runningchain (and blocking the entire budget when the pod never started). It now returns as soon as the CR is admitted; the background watch loop surfaces the agent and attaches its session when it reachesRunning— the synchronous poll was redundant with that path (#157). - The interactive reply box now appears for k8s agents. The dashboard shows
it only when an agent is
interactiveandidle, and underPROSPERO_FLEET=k8sneither was sourced correctly — the status/interactive fields are now read from the pod's caliband rather than theCalibanTaskCR alone (#130) (#156).
0.3.1 - 2026-07-14
Bug-fix follow-up to the 0.3.0 Kubernetes config plane, closing the four issues
surfaced in k8s smoke testing on a fresh 0.3.0 deploy. The PROSPERO_FLEET=k8s
fleet now stays Ready through a schema-skewed custom resource, surfaces the
registered Workspace CRs it manages (instead of a synthetic phantom), and
rejects an unregisterable workspace up front. Local behavior is unchanged.
Fixed
- A single un-deserializable
CalibanTaskno longer wedges the whole fleet.K8sFleet's watch/readiness path listedCalibanTasks strictly, so one CR that failed to deserialize (e.g. a stale task predating the now-requiredworkspaceReffield) failed the entire poll — the fleet never populated,/readyzstuck at503, and the pod never becameReady. The list is now decoded per-item, skipping and logging the bad CRs (#148) (#152). - The k8s fleet snapshot reconciles with the
Workspaceregistry.GET /api/fleetsynthesized a single phantomk8sworkspace and never read the registeredWorkspaceCRs, so a registered workspace was invisible in the dashboard while the synthetick8sentry reportedworkspace not registered. The snapshot now surfaces the registeredWorkspaceCRs (agents grouped by the workspace they reference), so/api/fleetand/api/workspacesagree and a fresh deploy shows no phantom (#149, #151) (#153). - Add-workspace rejects an invalid workspace as
400, not a raw apiserver422. The dashboard's+ add workspaceposted aWorkspacewith emptyproviders/sources, which the CRD (minItems: 1on both) rejected — so a workspace could never be registered from the dashboard. The config plane now validates at least one well-formed source and provider before apply (add and edit paths), and the form validates the same client-side (#150) (#154).
0.3.0 - 2026-07-12
The Kubernetes config plane: deploying with PROSPERO_FLEET=k8s is now a
real control plane — create and configure workspaces, and launch provider-bound
agents, from the dashboard — instead of a read-only viewer that returned
405 Method Not Allowed on Save. Workspaces are first-class Workspace custom
resources reconciled by caliban-operator, and the dashboard is backend-aware.
Local behavior is unchanged.
Added
- Kubernetes config plane (core + API). Under
PROSPERO_FLEET=k8s,K8sFleetnow wires aFleetAdminover operator-ownedWorkspacecustom resources, soPOST/PUT/DELETEon/api/workspacespersist and manage real configuration — multi-source workspaces, a named-provider list, and per-provider credentials referenced by KubernetesSecretname (prospero never reads the Secret) — instead of returning405. A backend-neutralWorkspaceConfigDTO lets one API serve both backends (local projects its single-provider subset, unchanged);GET /api/workspacesreturns the realWorkspaceCRs with reconciliation status; async workspace writes answer202 Accepted; and a spawned agent binds a named provider viaproviderRef(#142) (#144, #145). - Backend-aware dashboard. The dashboard fetches
GET /api/capabilitiesand adapts. On k8s it renders a workspace editor (git sources + a named-provider list withsecretName/keySecret references and a default marker), reconciliation status pills (pending/reconciling/ready/failedwith the failure message on hover), and a launch-modal provider picker; on local it is byte-for-byte unchanged (#143) (#146). GET /api/capabilities— a backend capability seam the dashboard gates its controls on (#99) (#101).- Frontmatter / agent-template support through spawn — a spawn can forward an
agent-template markdown file to caliband's
SpawnSpec.frontmatter_path(#6) (#102). - Guiding Principles & Invariants guide page synthesizing ADRs 0002–0009 (#74) (#104).
Changed
- The
CalibanTaskCRD mirror moved from an inlineworkspaceto aworkspaceRef(plus an operator-pinnedstatus.resolvedWorkspace), matching caliban-operator's frozenv1alpha1contract. Pre-v1; existing cluster CRs are recreated under the new schema.
0.2.0 - 2026-07-11
Kubernetes high-availability, a reworked dashboard, and a full QA sweep. A
second QA pass over the real prospero/caliband stack filed 23 findings; all
are fixed here, alongside first-class leader election for the k8s fleet backend
and a new agent-timeline dashboard.
Added
- Leader election + attach lifecycle for the
K8sFleetbackend. The session-plane attach — the one path that writes an agent's events to the shared store/bus — is now gated on a per-agent ownership lease, so with 2+prosperodreplicas exactly one replica owns, attaches to, and emits each agent (no more duplicate SSE events or racing per-streamseqallocation). Standalone is unchanged (SelfOwnsAll); a clustered deploy builds aLeasedOwnershiplease plus heartbeat. Attach tasks are now promptly torn down on stop/remove/restart, and any agent observedRunning— including operator- or peer-created ones — is streamed by the lease owner (#108, #112, #113) (#138). - Dashboard agent timeline, tool-call inspector, and run header — a folded event timeline with expandable tool-call segments and a per-run turns/outcome header (#5) (#96).
prospero-typescrate — the normalizedFleetEvent/model DTOs extracted into a small, wasm-compatible serde-only crate the WASM dashboard can share (#98) (#100).
Changed
- Under
PROSPERO_FLEET=k8s,prosperodno longer builds a localFleetManager/poll loop; the k8s backend serves directly over the shared store/bus (#83) (#92). /readyznow reportsworkspaces_total/workspaces_healthy/workspaces_unreachable(wasrepos_*), and user-facing error wording says "workspace" not "repo", matching the vocabulary used everywhere else (#116, #117) (#135).
Fixed
- Dashboard. Terminal-agent SSE streams no longer reconnect-storm into an
unbounded, duplicated timeline with runaway memory
(#105)
(#128); tool calls resolve
ok/failinstead of showing "running" forever (paired bytool_use_id) (#106) (#131); the fleet summary shows the workspace count, the misleading$0.0000cost is gone, and a favicon is served (#115, #109, #119) (#134). - API. Duplicate workspace registration returns
409 Conflict, not a misleading503(#111) (#139); an unknown agent's events endpoint returns404instead of200 [](#118) (#135);api_key_from_envon a keyless provider is rejected at config-set time, andrmno longer races a just-spawned agent or lags the fleet view (#120, #122, #123) (#137). - k8s hardening. The session-plane bearer token is never sent over plaintext
(#107)
(#133); unrecognized
CalibanTaskphases map to a terminal state,calibandEndpointis validated, lock poisoning can't wedge the fleet view, the token compare is constant-time, and--fleet-backend k8son a non-k8s build fails before any side effects (#114, #121, #125, #126, #127) (#136). - Tests. De-flaked the
distributed_busPG suite under parallel shared-DB load (#110) (#129) andcli_drives_the_full_stack(#85) (#94).
0.1.1 - 2026-07-05
Fixed
- The released image now builds
prosperodwith--features k8s, so theK8sFleetbackend is compiled in andPROSPERO_FLEET=k8sworks. Previously the image only ran the local backend, so an in-cluster deploy showed an empty fleet (#90). Unblocks the k8s-fleet-backend support in the prospero Helm chart.
0.1.0 - 2026-07-04
Initial containerized and licensed release of the prospero control plane —
the agent orchestration layer that sits above many caliband supervisors — as
part of the P0 Kubernetes deployment (epic
caliban-ai/caliban#274).
Added
ghcr.io/caliban-ai/prospero:0.1.0— multi-arch (linux/amd64 + linux/arm64), non-root container image runningprosperod(REST + SSE + dashboard on 7878); also tagged:latestand:sha-<commit>.- Helm chart
charts/prosperoin caliban-ai/helm-charts, rendering standalone (SQLite + PVC) or clustered (external Postgres, N replicas) from onetopologyvalue.
Changed
- Repository relicensed to AGPL-3.0-only, matching its sibling projects.
Guiding Principles & Invariants
Prospero's design philosophy is recorded in the architecture decisions. This page synthesizes those decisions into the guiding principles, the inviolable invariants, and the scale-out roadmap — the why behind the code, in one place. It complements the ADR log; it does not replace it. Every item cites the ADR(s) it derives from; on supersession, keep this page in sync.
Scoping note. The unit a
calibandmanages is a workspace of 1..N repo sources, not a single repo. Per-repo is only today's implementation (caliband identity =hash(repo_root)), being generalized in lockstep by caliban #281 (supervisor) and prospero #72 (discovery). Read "per-repo" phrasings below as transitional.
Guiding principles
-
Control plane, not re-implementation. Prospero owns fleet lifecycle (spawn / kill / respawn / attach) and adds only the fleet-wide concerns caliban lacks — it never re-implements the agent runtime. The mechanism sits behind the
FleetProvidertrait: Local drives an existing caliband over the wire; K8s realizes the same verbs declaratively as CRUD onCalibanTaskcustom resources. (ADR 0002, ADR 0008) -
Couple through the wire, nothing else. Caliban's NDJSON wire format (and, for k8s, the
CalibanTaskCRD's serialized form) is the only contract. Prospero mirrors the serde types and depends on no caliban crate — so the two projects evolve independently and integration breakage surfaces as a data-shape test, not a compile error. (ADR 0003) -
Hybrid observability: live + durable, unified. Status comes from polling
List; detail comes from attach-on-demand; and every normalized event is appended to durable storage, so an agent's history survives after it finishes. Live and durable views are one read path. (ADR 0004) -
Normalize caliban's frames into a stable internal type. Raw
stream-jsonframes becomeFleetEvent/FleetSnapshot; consumers never see raw frames. The normalizer is forward-compatible — an unknown frame is skipped and logged, never fatal. (ADR 0003, ADR 0004) -
Safe-by-default isolation. Worktree isolation is the default for every spawn; sharing the working tree is an explicit opt-out (
--shared-tree). Isolation stays per-source even as scoping moves to the workspace. (ADR 0005) -
Enforce policy at the boundary, in one place. Defaults like worktree isolation are set at the API boundary, so every client (CLI, dashboard, future callers) inherits the same policy from a single control surface. (ADR 0005, ADR 0006)
-
Layered, one-directional crate boundaries.
cli/daemon→api→core, acyclic; no web framework leaks intocore's public API. Shared read-model DTOs live in a wasm-compatible leaf crate so the (native) server and a (WASM) dashboard cannot drift. (ADR 0006) -
Abstraction behind traits for deferred evolution. Persistence sits behind a
Storetrait (realized as jsonl / sqlite / Postgres); fleet control sits behindFleetProvider(Local / K8s). Traits are the vehicle for scale-out and workspace scoping without touching call sites. (ADR 0004, ADR 0008) -
Authorize declaratively, fail closed, attribute everything. Every non-probe request carries a named token with one hierarchical scope (
read<operate<admin), checked by middleware against a per-route scope table that defaults an unlisted route toadmin. Tokens are declared as hashes in a file, never stored in a database, so revocation is a file (or Secret) change rather than shared cross-replica state — and every mutation is attributed to the token's name. (ADR 0010)
Inviolable invariants
These hold across every backend and topology; a change that breaks one is a design change, not a refactor.
- No caliban crate dependency. The wire format / CRD serialized form is the sole coupling. (ADR 0003)
- Acyclic layers. Dependencies flow one way —
cli/daemon→api→core— andcore's public API names no web framework. (ADR 0006) - Durability before divergence. A normalized event reaches durable history; if an append fails, that gap is itself recorded so live and durable views never silently disagree. (ADR 0004)
- Unknown frames never crash. The normalizer tolerates unrecognized frames by skip-and-log. (ADR 0003)
- Isolation is the default, opt-out is explicit. No spawn shares the working
tree unless a caller says so. (ADR 0005)
Held by the local backend. Under k8s per-run isolation is not a
CalibanTaskfield and nothing populates the workspace-level one for an API-spawned task, so agents there share the checkout — see Spawning. The invariant stands as the decision; the k8s backend does not yet meet it. - Backends are interchangeable behind the trait. Local and K8s implement the
same
FleetProviderverbs and emit to the same observability plane; the API request path is backend-agnostic. (ADR 0002, ADR 0008) - The fake is a faithful double. The control plane is testable end-to-end against an in-process fake caliban, so backends are correct by construction, not by hope. (ADR 0007)
- Authorization fails closed. A route absent from the scope table requires
admin; with tokens configured every request outside the small open set (probes, the dashboard shell, sign-in) needs a credential, loopback included; and serving unauthenticated beyond loopback must be asked for explicitly. (ADR 0010) - Authenticated identity and asserted identity never merge.
actoris the token prosperod authenticated.on_behalf_ofis what the client said, is never verified, and is always recorded beside the credential that claimed it. (ADR 0010)
Scale-out roadmap
The trait seams above exist so prospero can grow along three independent axes without disturbing the request path.
- Backend: Local → K8s.
LocalFleetdrives a caliband over Unix sockets;K8sFleetrealizes the sameFleetProviderverbs as CRUD + watch onCalibanTaskCRs, which the caliban-operator reconciles into sandboxed caliband pods. Both emit to the sharedStore/EventBus. (ADR 0008) - Scope: per-repo → workspace. A caliband manages a workspace of 1..N
source checkouts; today's per-repo identity (
hash(repo_root)) is being generalized in lockstep with caliban #281 and prospero #72. - Topology: standalone → clustered. Standalone runs sqlite + an in-process
bus + self-owned streams; clustered runs a Postgres store/config, a
LISTEN/NOTIFY event bus, and leased stream ownership so replicas fail over
without double-writing. Both sit behind the
Store/EventBus/Ownershipseams. (ADR 0004)
License
Prospero is licensed AGPL-3.0-only. (ADR 0009)
Architecture Decision Records
- ADR 0001 · Record architecture decisions
- ADR 0002 · Prospero is a control plane over caliband, not a re-implementation
- ADR 0003 · Couple to caliban only through its NDJSON wire format
- ADR 0004 · Hybrid live + durable observability behind a
Storetrait - ADR 0005 · Worktree isolation by default for agent spawns
- ADR 0006 · Layered crate boundaries: cli/daemon → api → core
- ADR 0007 · Test the control plane against an in-process fake caliban
- ADR 0008 ·
K8sFleet— a KubernetesFleetProviderbackend - ADR 0009 · License prospero under AGPL-3.0-only
- ADR 0010 · Inbound API authentication with scoped, declarative tokens
- ADR 0011 · ACP is a second drive protocol, not a second lifecycle
ADR 0001 · Record architecture decisions
- Status: accepted
- Date: 2026-06-13
Context
Prospero's architectural decisions — the coupling boundary to caliban, the
observability model, the crate layout, the testing strategy — have so far lived in PR
descriptions, commit messages, design docs under docs/superpowers/, and chat. Those
sources answer what the system does but scatter the why. Design docs cover whole
features at once and go stale; commit messages are hard to discover after the fact. As
the project grows and more people touch it, reconstructing the rationale behind a
decision means archaeology across several places.
We need a durable, discoverable, append-only record of significant decisions that survives independently of any one feature's design doc.
Decision
We will keep Architecture Decision Records (ADRs) in this repository under
docs/adr/, one decision per file, named docs/adr/####-topic.md (zero-padded,
monotonically increasing number + kebab-case slug).
Each ADR records the context, the decision, and its consequences in a short,
lightweight format (see template.md). ADRs are immutable once Accepted;
a decision is changed by writing a new ADR that supersedes the old one rather than by
editing history. The process is documented in README.md.
We are seeding the directory with records for architectural decisions already made and documented elsewhere (ADRs 0002–0007), so the practice starts with real content rather than an empty convention.
Consequences
- Positive: the rationale behind significant decisions has a single, version-controlled home that outlives individual design docs and PRs. Reviewers gain a lightweight place to record "why" during normal development. ADRs are additive and immutable, so the decision log only grows; superseded records stay in place with a pointer forward, preserving the full history.
- Negative: one short file per significant decision, and the team must remember to
write an ADR when a decision is architecturally significant. The
README.mdgives the "when to write one" bar to keep this from degrading into either noise or neglect. - Revisit if: the practice degrades into noise (trivial decisions getting ADRs) or neglect (significant decisions going unrecorded) — a sign the "when to write one" bar needs tightening or the format is too heavy to sustain.
ADR 0002 · Prospero is a control plane over caliband, not a re-implementation
- Status: accepted
- Date: 2026-06-05
- Source:
docs/superpowers/specs/2026-06-05-prospero-framework-design.md§1
Context
Caliban already ships caliband, a per-repo supervisor daemon that spawns, lists, kills,
respawns, and attaches to background agents over a Unix-socket NDJSON protocol. Prospero's
job is to launch, manage, and observe many agents across many repositories at once.
We could either (a) re-implement process supervision ourselves to own the full stack, or (b) build a layer above the existing calibands that delegates supervision to them and adds only the fleet-wide concerns caliban lacks.
Decision
Prospero is a control plane. It discovers and drives the existing per-repo caliband
daemons and does not re-implement process supervision. Prospero sits above many
calibands and adds what they individually lack:
- a fleet-wide model aggregating agents across repos and hosts,
- durable run history (caliband exposes only live state),
- a normalized event type independent of caliban's wire frames,
- the observability/control surfaces: CLI, HTTP/JSON API, SSE, and a minimal dashboard.
Consequences
- Positive: we avoid duplicating — and diverging from — caliban's supervision logic; spawn/kill/respawn/attach semantics stay defined in one place. Prospero's value is concentrated on the genuinely new concerns (fleet aggregation, history, normalization) rather than re-litigating process management.
- Negative: Prospero depends on a running
calibandper managed repo and inherits its behavior and limitations (Discovery can autostart a caliband on demand to soften this), and the boundary between the two systems must be defined precisely — see 0003. - Revisit if: caliban's supervision proves too limited for fleet needs, or a concern we treat as fleet-wide turns out to belong inside the per-repo daemon — either would move the control-plane / supervisor boundary.
ADR 0003 · Couple to caliban only through its NDJSON wire format
- Status: accepted (amended by 0011)
- Date: 2026-06-05
- Source:
docs/superpowers/specs/2026-06-05-prospero-framework-design.md§3, §4
Context
Prospero needs to talk to caliband: send control requests (List, Spawn, Attach,
Kill, …) and read per-agent stream-json frames. Caliban implements this in Rust crates
(e.g. caliban-supervisor) that Prospero could depend on directly to reuse the request,
reply, and SpawnSpec types.
Depending on caliban's crates would tie Prospero to caliban's internal Rust API, version cadence, and transitive dependencies — coupling far wider than the bytes actually exchanged on the socket.
Decision
The caliband wire format is the only contract. Prospero owns a thin NDJSON client
in prospero-core (CalibandClient) with its own mirrored serde types
(CtlRequest / CtlReply / AgentRecord / SpawnSpec) and newline-delimited framing over
tokio::net::UnixStream. Prospero does not depend on caliban-supervisor or any other
caliban crate.
Consequences
- Positive: Prospero and caliban evolve independently; the only thing that must stay compatible is the bytes on the wire, which is also the surface real deployments depend on. Because the coupling is just a socket protocol, the entire control plane can be tested against a fake that speaks the same protocol — see 0007. Unknown/forward-compatible frames are tolerated by the normalizer (skip-and-log), so caliban can add frame types without breaking Prospero.
- Negative: the wire types are mirrored, so a protocol change in caliban requires a corresponding edit in Prospero's client — an intentional, explicit seam rather than a silent transitive break, but a seam someone must remember to keep in sync.
- Revisit if: the wire protocol churns fast enough that hand-mirroring the types becomes a recurring source of drift — a generated client or a shared schema crate might then earn its coupling cost.
Amendment (2026-09-19, ADR 0011)
Two refinements, neither of which changes the rule that prospero couples to caliban through a wire format rather than its crates:
- There is now a second drive protocol. ACP (an open, third-party protocol) may carry an agent's data path instead of the NDJSON attach stream, chosen per spawn. Control — spawn, list, kill, drain — stays on caliband's NDJSON wire.
- The "revisit if" clause above has fired. The hand-mirrored control/NDJSON types are
replaced by caliban's published
caliban-contractcrate (#239): serde-only, no daemon internals, so it is not the wide coupling this ADR rejected.
ADR 0004 · Hybrid live + durable observability behind a Store trait
- Status: accepted
- Date: 2026-06-05
- Source:
docs/superpowers/specs/2026-06-05-prospero-framework-design.md§1, §4, §5
Context
Caliban exposes only live state: it can List agents and stream a stream-json tail
while an agent is active, but it keeps no history — once an agent finishes, its story is
gone. Prospero must show both a cheap fleet-wide status overview and a per-agent detail
stream, and that detail must survive after the agent (and caliband's memory of it) is gone.
Options ranged from pure polling (cheap, but no streaming detail and no history) to attaching to every agent's stream continuously (rich, but expensive and still no history).
Decision
Adopt a hybrid model:
- Poll each caliband's
Liston an interval for cheap, fleet-wide status reconciliation. - Attach to a per-agent stream on demand — while the agent is active or a client is watching — and stop when it is terminal and unwatched (work stays proportional to active + watched agents).
- Normalize both onto an in-memory
FleetSnapshotand a normalizedFleetEventtype, and also append every event to durable storage behind aStoretrait. The first implementation isJsonlStore(append-only JSONL log + registry persistence).
A client that starts watching gets replay from the Store then a live tail from the
broadcast bus, joined on a monotonic seq — so "observe" means live + history, unified,
and the full story persists on disk even after caliban forgets the agent.
Consequences
- Positive: Prospero fills caliban's history gap — runs are durable and replayable, and
seqsurvives prosperod restarts. Streaming cost is bounded to active/watched agents rather than the whole fleet. Putting persistence behind aStoretrait keeps the door open for a sqlite backend later without touching the rest of the system;JsonlStoreis deliberately the simple first step. - Negative: durability is best-effort relative to liveness — a failed
Store.appendis logged and metered but does not stop live SSE, so we favor a never-down fleet view over guaranteed persistence (first stab). Log retention/rotation is deferred. - Revisit if: dropped-event durability becomes unacceptable (history is relied on as a
system of record), or
JsonlStoreoutgrows append-only files — either would promote the sqlite backend theStoretrait was designed to allow.
ADR 0005 · Worktree isolation by default for agent spawns
- Status: accepted
- Date: 2026-06-05
- Source:
docs/superpowers/specs/2026-06-05-prospero-framework-design.md§1, §2, §5
Context
A core use case is running several parallel agents on the same codebase at once —
multiple streams of work under one repo's caliband. If those agents share a single working
tree, their concurrent edits collide and corrupt each other's work. Caliban's SpawnSpec
exposes an isolation_worktree flag that gives each agent its own git worktree.
The question is the default: do agents share the tree unless told otherwise, or get an isolated worktree unless told otherwise?
Decision
Worktree isolation is the default for every spawn. Each agent gets its own git worktree
so concurrent edits on one codebase don't collide. Sharing the working tree is an explicit
opt-out via the --shared-tree flag (which sets isolation_worktree: false).
This default is enforced at the API boundary: POST /api/repos/{repo}/agents defaults
isolation to worktree, so the CLI, the dashboard, and any future client inherit the safe
behavior without each re-deciding it.
Consequences
- Positive: the common case — parallel agents on one repo — is safe by default; a user
has to go out of their way (
--shared-tree) to opt into shared-tree behavior and its collision risk. Enforcing the default at the API boundary, not per client, keeps the policy in one place and makes "spawn defaults to a worktree" a testable invariant of the control plane. - Negative: each isolated agent consumes a git worktree (disk + setup cost) — acceptable for the parallelism it buys, but real cost for many or short-lived agents.
- Revisit if: worktree setup cost dominates for high-churn or single-agent workloads, or a use case emerges where shared-tree is the safe common case — either would argue for a different default or a per-repo policy.
ADR 0006 · Layered crate boundaries: cli/daemon → api → core
- Status: accepted
- Date: 2026-06-05
- Source:
docs/superpowers/specs/2026-06-05-prospero-framework-design.md§4
Context
Prospero is a CLI, a long-running daemon, an HTTP/SSE API, and an orchestration engine. Put in one crate, the web framework, transport, and process concerns would bleed into the domain logic, and tests of the core engine would drag in axum and a running server.
We need crate boundaries that keep the orchestration brain independent of how it is exposed.
Decision
Split the workspace into four crates with one-directional dependencies:
prospero-cli (prospero) ─┐
├─▶ prospero-api ─▶ prospero-core
prospero-daemon (prosperod)┘
prospero-core— the orchestration brain: domain model,CalibandClient, discovery, registry,Store,FleetManager. No web framework in its public API.prospero-api— an axum adapter (REST + SSE + dashboard assets) overFleetManager; depends only oncore.prospero-daemon(prosperod) — process entry: owns the tokio runtime, config, logging, shutdown; wirescore+apiinto a server.prospero-cli(prospero) — a thin client that talks toprosperodover HTTP, not a second protocol. Nothing depends on the daemon.
Consequences
- Positive: the core engine is testable with no HTTP server and no web types in scope,
and
apiis testable in-process over a fake-backedFleetManager. One control surface, not two: the CLI and the dashboard both go through the HTTP API, so there is a single place where control/observe semantics live. The acyclic, one-way dependency graph keeps responsibilities from leaking upward (e.g. transport concerns can't seep into the domain model) and makes the boundaries easy to reason about as the system grows. - Negative: four crates plus the one-way rule impose a structure cost — types that span layers must be placed deliberately, and an in-process call becomes an HTTP round-trip for the CLI rather than a direct function call.
- Revisit if: the crate split adds more ceremony than it prevents leakage (e.g. constant re-exports across boundaries), or a client genuinely needs a control path that HTTP can't serve well — either would pressure the layering.
ADR 0007 · Test the control plane against an in-process fake caliban
- Status: accepted
- Date: 2026-06-05
- Source:
docs/superpowers/specs/2026-06-05-prospero-framework-design.md§7
Context
End-to-end testing of Prospero would normally require a real caliband, real agents, API
keys, and live LLM calls — slow, non-deterministic, expensive, and awkward in CI. Yet the
behavior worth testing (spawn defaults, poll reconciliation, attach/normalize, replay-then-
tail, resilience to dropped streams and refused sockets) is exactly the control-plane logic
that sits between Prospero and caliban.
Because the only coupling to caliban is the NDJSON wire format (0003), we can substitute anything that speaks that protocol.
Decision
Build an in-process fake caliban as the cornerstone of the test strategy: a harness
(shipped in prospero-core behind a testkit feature) that listens on a real Unix socket
and speaks the same NDJSON control + per-agent stream protocol as the real daemon. Tests
drive the real FleetManager / CalibandClient against this fake.
This enables deterministic, end-to-end testing of the whole control plane — including the CLI-through-HTTP path — with no real caliban, no API keys, and no LLM calls.
Consequences
- Positive: the full stack is tested fast and deterministically in CI; scripted frames
make event sequences, resilience cases, and
seqrecovery reproducible. Pure units (framing, normalizer, discovery resolution, store, reconciliation) are tested directly; the fake is reserved for integration-level behavior that needs the protocol. - Negative: the fake must track the wire protocol — if caliban's protocol drifts, the fake and the mirrored client must be updated together (the same explicit seam noted in 0003). Tests against a real caliban binary + live model remain out of scope for the first stab (manual / CI-gated later), so the fake covers the wire contract, not caliban's own correctness.
- Revisit if: the fake and the real caliban diverge in behavior the wire contract doesn't capture (green tests, broken integration) — that gap would justify a real-caliban smoke test in CI alongside the fake.
ADR 0008 · K8sFleet — a Kubernetes FleetProvider backend
- Status: accepted
- Date: 2026-07-04
- Source: k8s system-design spec (§"prospero changes", §"The two planes") in the caliban-ai docs hub · prospero #64 · epic caliban#274 · builds on prospero #71/#75 (caliband network transport) · relates to 0003, 0006, 0007
Context
ADR 0006 put fleet control behind the
FleetProvider trait (prospero #63); LocalFleet (caliband-over-Unix) is the only
backend. The k8s epic needs a second backend, K8sFleet, that drives a fleet by
CRUD + watch on CalibanTask custom resources — the caliban-operator (caliban
#283) reconciles each CalibanTask into a sandboxed caliband pod exposing a stable
DNS endpoint — and connects the live session plane to that pod over the network.
The network transport this needs already landed in prospero #71/#75
(caliband/transport.rs): CalibandClient can now dial a caliband over TCP + rustls
TLS + a bearer-token preamble (connect_tcp), spawn/attach return an
Endpoint, and AgentHandle.endpoint: Endpoint carries a Unix path or a
host:port. So K8sFleet composes an existing transport; it does not build one.
What remains for K8sFleet:
- a client-side
CalibanTasktype and akubeclient to CRUD/watch it; - the four
FleetProvidermethods mapped onto CR operations; - a session-plane bridge that dials each agent's
Endpoint::Tcp(Sandbox DNS) over #75's transport and feeds prospero's existing event bus + store, so the dashboard/SSE work unchanged; - reuse of the 0007 conformance suite, which is
Unix-
FakeCaliband-coupled and must be generalized to a fake backend.
Decision
-
Mirror a minimal
CalibanTasktype; do not depend on the caliban-operator crate. Per ADR 0003's "couple only through the wire" principle (here, the CRD's serialized form), declare a minimalkube::CustomResource(caliban.caliban-ai.dev/v1alpha1) inprospero-corecarrying only the fieldsK8sFleetsets (workspace.sources,task.prompt, optionalisolation) and reads (status.phase,status.calibandEndpoint,status.sandboxRef). A golden test pins it against a sample CR. The operator's CRD is the source of truth; the mirror is kept minimal to limit drift. -
K8sFleetimplementsFleetProvideroverCalibanTaskCRs. Newprospero-coremodule behind ak8scargo feature (soLocalFleet-only builds pull nokube).ensure_agent(spec)→ server-side-apply aCalibanTask(deterministic name from a hash of the spec, so it is idempotent); awaitstatus.phase = Running+status.calibandEndpoint; returnAgentHandle { endpoint: Endpoint::Tcp(calibandEndpoint) }.watch_fleet()→ akube::runtime::watcheronCalibanTask→ translate applied/deleted +phasetransitions intoFleetChange::{Discovered,StatusChanged,Gone}(mapPhase→AgentStatus), seeded by an initial list.stop_agent(id, drain)→ delete theCalibanTask(the operator's owner-ref GC tears down the Sandbox);Gracefulbest-effort awaits deletion within the timeout.restart_agent(id)→ delete + re-apply (fresh name → fresh id).
-
The session plane dials the agent
Endpointover #75's transport and feeds the existing bus + store.K8sFleetcarries its own attach task built onCalibandClient::connect_tcp+ the sharedstreamnormalizer +Emitter, so/streamSSE and history work unchanged (they read the bus/store, never a socket — ADR 0004). TLS root + bearer token come from operator-injected config (env/Secret; Sandbox DNS is the host). The attach-loop core is refactored out ofFleetManagerinto a provider-agnostic helper if cheaper than duplicating. -
Generalize the conformance suite behind a
FakeBackendtrait. Replacefleet_provider_conformance(provider, fake: &FakeCaliband)with(provider, backend: &dyn FakeBackend)whereFakeBackend { received_any_spec(); simulate_reap(id) }.FakeCalibandimplements it trivially (its existingreceived_specs/remove_agent); a new in-memoryFakeK8simplements it forK8sFleet.LocalFleet's existing conformance run is unchanged. This keeps ADR 0007's "test the control plane against a fake" property for both backends. -
Backend selection at the daemon edge.
prosperodchoosesLocalFleetvsK8sFleetby config/env (mirroring caliban's--database-urltopology switch — e.g.PROSPERO_FLEET=local|k8s+ namespace/kubeconfig). The API layer's remaining directFleetManagercalls (kill/respawn/steer/snapshot) are an ADR 0006 P1 limitation tracked separately;K8sFleetMVP wires the four provider methods + the session plane.
Consequences
- prospero gains a Kubernetes fleet backend —
kubectl-less fleet control viaCalibanTaskCRs, with live streaming over the pod network. This completes the epic's "two planes" for the k8s path (declarative CRs + real-time session over #75's transport). - prospero takes its first
kube/k8s-openapidependency, scoped toprospero-corebehind thek8sfeature;LocalFleetbuilds and runs with no cluster. - A second mirrored seam (the
CalibanTasktype vs the operator's CRD) joins the wire mirror of ADR 0003 — the same manual-sync tradeoff, kept minimal and golden-pinned. - The conformance suite becomes backend-agnostic, so future backends (remote — prospero #1) reuse it for free.
- Deferred: gRPC (caliban #314); rerouting the API's direct
FleetManagercalls through the provider seam; warm pools / multi-tenant (epic P4). The finalizer-drain / checkpoint pairing waits on caliban checkpoint gRPC.
ADR 0009 · License prospero under AGPL-3.0-only
- Status: accepted
- Date: 2026-07-05
- Source: prospero #73 · in force since the prosperod image work (P0 #62,
workspace.package.license = "AGPL-3.0-only") · relates to 0002
Context
Prospero shipped its workspace with license = "AGPL-3.0-only" (set alongside
the prosperod container image, P0 #62) but never recorded why — the decision
lived only in a Cargo.toml field and a commit message. Prospero is a
network-facing control plane (0002):
its primary deployment shape is a long-running daemon (prosperod) plus a
dashboard that users reach over the network, increasingly in-cluster (the k8s
epic, 0008). A license needs recording as an ADR so
the choice — and its obligations — are explicit for contributors and operators.
Options weighed:
- AGPL-3.0-only. Strong copyleft whose §13 network clause extends the source-availability obligation to users who interact with a modified version over a network, not only to those who receive a binary. Matches the rest of the caliban-ai stack (caliban, gonzalo), which is AGPL.
- A permissive license (MIT/Apache-2.0). Maximizes adoption and frictionless embedding, but lets a network operator run a modified prospero as a service without ever sharing changes — the exact gap AGPL closes, and the wrong default for a network control plane.
- GPL-3.0-only. Copyleft but without the network clause, so a hosted (SaaS) prospero could diverge privately — strictly weaker than AGPL for this deployment shape.
Decision
We will license all prospero crates under AGPL-3.0-only, matching the
caliban-ai stack. workspace.package.license = "AGPL-3.0-only" is the source of
truth; every crate inherits it via license.workspace = true. Network use of a
modified prospero carries the AGPL §13 source-availability obligation.
This ADR records a decision already in force; it changes no code.
Consequences
- Positive: the copyleft obligation reaches network users of a modified prospero (its dominant deployment), keeping hosted forks source-available; the whole stack (caliban/gonzalo/prospero) shares one coherent license story; the rationale is now discoverable, not buried in a manifest field.
- Negative: AGPL deters some downstream adopters (notably orgs with policies banning AGPL dependencies), narrowing embedding/commercial-integration reach; contributors must be comfortable with copyleft.
- Revisit if: a dual-licensing or commercial-exception model is needed for adoption, or the stack's license stance changes — at which point this ADR is superseded rather than edited (ADRs are immutable once accepted).
ADR 0010 · Inbound API authentication with scoped, declarative tokens
- Status: accepted
- Date: 2026-09-13
- Source:
docs/superpowers/specs/2026-09-13-api-auth-design.md
Context
prosperod's REST + SSE API was built for a trusted operator on localhost; the
framework spec made authentication a non-goal. That assumption no longer holds:
the container binds 0.0.0.0, the homelab deployment serves the API through
Traefik at prospero.hexadecimate.net behind only a LAN IP match, and anything
that reaches the port can spawn and kill agents and rewrite workspace provider
configuration. Ariel's command authorization (caliban-ai/ariel#5) and the fleet
MCP server (#218) both need a credential model to rely on.
Options weighed:
- Scope: a single shared token (smallest, but no read-only access or per-client revocation); named tokens with scopes; full users + RBAC (overlaps gonzalo's people/grants records that Ariel owns); mTLS client certificates (awkward for the browser and CLI).
- Storage: a declarative file/Secret; runtime management through the API and config store (instant revocation, but migrations, cross-replica invalidation and a bootstrap problem); both.
- Trust model: caliban's drive-surface gate (caliban-ai/caliban#527) always
trusts loopback peers. In a pod,
kubectl port-forwardand sidecars arrive as loopback, so that rule would bypass scopes. - Browser:
EventSourcecannot send anAuthorizationheader, so the dashboard needs a cookie, a token in the URL (leaks into history and logs), or an external auth proxy.
Decision
We will authenticate every non-probe request to prosperod with named API
tokens, each carrying one hierarchical scope — read < operate < admin —
enforced by a middleware layer in prospero-api against a per-route scope table.
- Tokens (
pspo_+ 32 random bytes) are declared in a tokens file of SHA-256 hashes (--api-tokens-file), mounted from a Kubernetes Secret in-cluster and generated offline withprospero token new. Revocation is a Secret change and rollout. There is no token database and no shared state across replicas. - When tokens are configured, every request needs one, loopback included.
With none configured, prosperod refuses a non-loopback bind unless
--insecure-no-authis set./healthzand/readyzare always open. - The dashboard signs in with a token and receives a stateless
HMAC-signed,
HttpOnly; SameSite=Strictsession cookie, keyed by a separate session-key Secret and bound to the token's current hash. Cookie-authenticated mutations must be same-origin. - Every mutation is attributed to the token name as
actor: on theFleetEventenvelope where the mutation records an event (local-fleet spawn and remove), otherwise in aninfolog line.
Per-person identity, SSO and inbound TLS are out of scope.
Consequences
- Positive: the API is safe to expose behind an ingress; Ariel, humans, monitoring and CI get separate, revocable, least-privilege credentials; the model works unchanged across standalone, clustered and k8s modes; the event log gains an audit trail; #218 and Ariel have one authenticator to reuse.
- Negative: every client must carry a token (CLI, dashboard sign-in, Ariel); revocation needs a restart and does not cut already-open SSE streams; deployments must ship a tokens Secret in the same rollout as the image, since a public bind without tokens now refuses to start; the trust model diverges from caliban's loopback-trusting gate.
- Revisit if: operators need runtime token creation or instant revocation; per-person accountability is required at the prosperod API rather than in Ariel/gonzalo; or prosperod starts terminating TLS or federating with an identity provider.
ADR 0011 · ACP is a second drive protocol, not a second lifecycle
- Status: proposed
- Date: 2026-09-19
- Source: #217. Amends 0003; builds on 0002 and 0008. Counterpart to caliban ADR 0059.
Context
Prospero drives caliban agents and nothing else. Per 0003
the coupling is caliband's NDJSON wire: control requests over the daemon socket, and a
per-agent attach stream of caliban stream-json frames that
caliband::stream::normalize_frame turns into EventKinds. Every consumer —
the event stores, the SSE tail, the dashboard timeline, /api/usage — is fed
from that one path.
Two forces now push against that:
- Heterogeneity. Four of the five evaluated competitors drive several agent products (§F in each matrix): OpenHands, GitHub Agent HQ, Vibe Kanban and OpenClaw. The Agent Client Protocol is the common denominator — Claude Code, Codex and Gemini CLI all speak it — and it is the most widely shared gap in the evaluation tree.
- Fidelity. The NDJSON attach plane carries a tool call's input and
ok/failed but was missing its result until caliban#391 (consumed in #236), and
it has no way to ask a human for a permission decision. That second gap is why
in-cluster agents were unusable before #238: an
Askwith no human attached is simply denied, and the refusal reaches nobody. ACP models both first class —tool_call_updatecarries result content, andsession/request_permissionasks the driver.
caliban has since shipped the transport. In v0.14.0 (ADR 0059, caliban#675) a
worker serves ACP over the same TLS + bearer-token per-agent listener prospero
already dials, selected per spawn by SpawnSpec.drive_protocol (ndjson |
acp). caliban#674 tracks the adapter's remaining data-parity gaps.
Four facts constrain what prospero can do with that:
- The worker's ACP mode is a fork, not an addition.
caliban/src/worker.rsreturns intorun_acp_workerbefore the NDJSON session plane is built: an ACP worker does not serve attach. Choosing ACP replaces prospero's observability path for that agent rather than supplementing it. - ACP inverts the run model. NDJSON is an autonomous agent with observers:
caliband starts the run and prospero attaches, possibly late, possibly more
than once, replaying from history. ACP is a passive server driven by exactly
one client: the driver calls
session/new, then eachsession/promptblocks for one turn and returns astopReason. Under ACP, prosperod is not an observer — it is the thing running the agent, and if it stops calling, the agent does nothing. - Parity gaps are still open (caliban#674). ACP's
tool_callcarries no input,TurnEnd/RunEndare dropped (no usage, cost or turn counts), andloadSessionis unwired. Adopting ACP for caliban agents today would regress the tool inspector (#236 ships input and result) and empty/api/usage. - A non-caliban harness has no caliband. No registry, no session dirs, no respawn, drain, idle-timeout or usage accounting. Those are caliband's, and 0002 is the decision not to re-implement them.
Three places an ACP client could live were weighed:
- A. In prosperod, dialing the per-agent port — for caliban agents, the same socket prospero already secures; caliband keeps the lifecycle.
- B. In caliband, hosting non-caliban children — caliband supervises a
foreign harness as a first-class agent, so prospero sees one contract.
caliban#296 opens this seam for sub-agents (the Agent tool's
ExternalHarness), not yet for top-level supervised agents. - C. In prosperod, as a process supervisor — a new
FleetProviderthat spawnsclaude-code-acp/codex-acp/gemini --acpitself and supervises them.
Decision
1. ACP is a second drive protocol for an agent's data path — never a second
lifecycle. We add an AgentDriver seam in prospero-core with two
implementations: today's NdjsonDriver (attach stream + AttachInbound) and a
new AcpDriver (JSON-RPC over the per-agent TLS+token listener). Both produce
the same EventKind stream and accept the same steering calls, so the stores,
SSE tail, dashboard and usage path stay protocol-agnostic. FleetProvider
(LocalFleet / K8sFleet) is untouched: caliband still spawns, registers,
kills and drains every agent (option A).
2. The protocol is chosen per spawn, defaults to NDJSON, and is not adopted
for caliban agents until caliban#674 lands. Prospero sets
SpawnSpec.drive_protocol from an explicit request. Until the adapter carries
tool-call input and TurnEnd/RunEnd accounting, switching an agent to ACP is a
regression in exactly the surfaces #236 and #181 built, so ACP ships behind an
opt-in and NDJSON remains the default for caliban agents.
3. ADR-0003 is amended, not superseded. Its rule — the wire format is the only contract; prospero depends on no caliban crate — still holds, now over two wire formats. Two refinements:
- ACP is an open, third-party protocol, not caliban's private wire. Its
types are prospero's own mirrors, which is also what lets
AcpDriverdrive a non-caliban agent unchanged. - For the caliband control/NDJSON half, the hand-mirror is replaced by the
published
caliban-contractcrate (#239) — the "revisit if" clause 0003 itself wrote down, now triggered. Depending on a serde-only contract crate is not the wide coupling 0003 rejected (that wascaliban-supervisor, the whole daemon).
4. Heterogeneous harnesses: prospero does not become a process supervisor
(reject C for now). Driving a foreign harness needs the lifecycle caliband
already owns; re-implementing registry, respawn, drain and usage inside prosperod
would contradict 0002 and would fork the
k8s story (a CalibanTask describes a caliban run, and a foreign process has no
Sandbox). The preferred route is B: caliband gains a top-level external-harness
backend — the natural extension of caliban#296's ExternalHarness seam from
sub-agents to supervised agents — so prospero keeps one lifecycle contract, one
CRD and one auth model, and gets heterogeneity through the seam this ADR already
builds (AcpDriver speaks to whatever is on the other end). If caliban declines
that, reopen C as its own ADR with the supervision cost stated explicitly.
5. Permission prompts are the payoff and are in scope. Under ACP a
supervised session surfaces session/request_permission; prospero answers it.
That means a pending-decision surface on the agent (API + dashboard approve/deny)
— the "supervised" half of #238's two postures, which today can only be honored
where a human is attached. The decision routes back as the ACP response;
allow_always (caliban#674) becomes a session-scoped allow.
6. Event mapping (normative). AcpDriver maps ACP session/update
notifications onto the existing EventKinds — no new variants, so every consumer
works unchanged:
| ACP | EventKind | Note |
|---|---|---|
agent_message_chunk | Output { stream: Stdout, chunk } | |
agent_thought_chunk | Output { stream: Thinking, chunk } | |
tool_call | ToolStarted { id, name, input } | input is Null until caliban#674 |
tool_call_update | ToolFinished { id, ok, result, truncated } | result from content, capped by TOOL_RESULT_CAP (#236) |
session/prompt returns stopReason | AgentFinished { outcome, cost_usd: 0.0, turns } | usage/turns are 0 until caliban#674 |
session/request_permission | (new) pending-decision state on the agent | answered over the same connection |
session/new result | AgentInit { session_id, model, tools } | tools may be empty |
Steering maps as well: send_input becomes the next session/prompt on the
session (a better fit than AttachInbound, which exists only because the NDJSON
worker is autonomous), end-input ends the session, and a kill is
session/cancel followed by caliband's existing Kill.
7. k8s expresses the protocol on the CR. CalibanTask.spec.task.driveProtocol
(ndjson | acp, absent ⇒ ndjson), mirroring exactly how permissionPosture
landed in #238 / caliban-operator#80 — prospero writes it, the operator validates
it, prospero reads it back when building the SpawnSpec. That is a
caliban-operator change and must land before the k8s path can select ACP.
8. Single-driver rule. An ACP agent has exactly one driver: the prosperod replica that owns it, under the existing ownership lease (#59/#108). A second replica must not open a session on the same agent. Dashboard viewers remain consumers of prospero's own SSE bus, never of ACP.
Consequences
- Positive. Prospero gains the two things NDJSON cannot express — tool
results with their input, and human permission decisions — on the connection it
already secures, with no new endpoint, no second auth model and no new
lifecycle. The
AgentDriverseam is the same seam heterogeneity needs later, so the §F gap is approached without prospero becoming a supervisor.FleetProvider, the stores and the dashboard are untouched, and the fake-caliband harness (0007) extends to a fake ACP peer the same way. - Negative. Two data paths to keep working, and ACP's is the strictly more
expensive one: prosperod must hold a session per agent and keep calling
session/prompt, so an agent's progress now depends on its driver staying up — a failover mid-turn is a lost turn, not a re-attach. Replay/resume is weaker untilloadSessionexists (caliban#674): a restarted prosperod can re-read its own stored history but cannot rejoin the agent's session. Accounting is absent on ACP until the same ticket. Selecting ACP per spawn is a user-visible knob whose wrong setting silently degrades the timeline. - Revisit if. caliban#674 stalls (then ACP stays opt-in indefinitely and this
ADR is aspirational); or caliban declines a top-level external-harness backend
(then option C — prosperod as supervisor — needs its own ADR with the
supervision cost accepted); or a second non-caliban harness turns out to need
per-harness lifecycle quirks in the driver, which would mean the
AgentDriverseam is the wrong abstraction and belongs one layer up, atFleetProvider.