Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 on 127.0.0.1:7878 by default.
  • prospero, an operator CLI. It is a thin HTTP client over that API.
  • The dashboard, a Rust → WASM app served by prosperod at /.

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 FleetEvent type. 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/usage aggregates 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

ProjectRole
calibanThe agent harness, plus caliband, the supervisor Prospero drives on the local backend.
prosperoThis project: the fleet control plane.
gonzaloA shareable persistence layer for caliban.
arielA chat bridge for the fleet: Discord today, Slack and Teams planned.
caliban-operatorReconciles 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

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 caliband on your PATH. prosperod starts one per workspace when none is running. Use --caliband-bin to 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 bypass tag 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

OptionEnvDefaultPurpose
--addr <URL>PROSPERO_ADDRhttp://127.0.0.1:7878Base 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

CommandPurposeScope
prospero workspace add <name> <root>Register a workspace by name and root pathadmin
prospero workspace listList workspaces with health, agent count, sources and providerread
prospero workspace config <name> [flags]Set the workspace's provider config and restart its calibandadmin
prospero workspace rm <name>Unregister a workspaceadmin

workspace config flags:

FlagMeaning
--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=VALUERaw env override (repeatable). Stored verbatim, so don't put secrets here

Each call replaces the whole configuration. Flags you leave out are cleared.

Agents

CommandPurposeScope
prospero spawn <workspace> <prompt> [flags]Launch an agent (worktree-isolated by default)operate
prospero lsList the fleet: every workspace and its agentsread
prospero statusReport whether the daemon is reachable, then list the fleetread
prospero follow <id> [--from N]Replay events from seq N (default 0), then stream live until the agent finishesread
prospero kill <id>Kill a running agentoperate
prospero respawn <id>Kill and respawn with the same spec; prints the new idoperate
prospero rm <id>Remove an agent from caliban's registryoperate
prospero send <id> <text>Send a user message to an interactive agentoperate
prospero end-input <id>Signal end-of-input to an interactive agentoperate

spawn flags:

FlagMeaning
--label <TEXT>Human-readable label
--model <MODEL>Model override
--shared-treeRun 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
--interactiveWait 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.

CommandPurposeScope
prospero automation add <id> <workspace> <task> [flags]Create an automationadmin
prospero automation lsList automations: trigger, workspace, last firingread
prospero automation run <id>Fire one now, whatever its triggeroperate
prospero automation runs <id> [--limit N]Recent runs, newest first (default 20)read
prospero automation disable <id>Stop it firing, without deleting itadmin
prospero automation enable <id>Let a disabled automation fire againadmin
prospero automation rm <id>Delete it and its run historyadmin

add needs exactly one trigger:

FlagMeaning
--schedule <CRON>5-field cron, in UTC, for example "0 3 * * *"
--webhookFire 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-treeRun in the workspace's working tree instead of an isolated worktree
--disabledCreate 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
FlagMeaning
--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
--jsonPrint 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

CommandPurpose
prospero token new <name> --scope <read|operate|admin>Generate a token. Runs offline and never contacts prosperod
prospero whoamiShow 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

FlagEnvDefaultPurpose
--addrPROSPERO_ADDR127.0.0.1:7878Bind address for the API and dashboard
--data-dirPROSPERO_DATA_DIR$XDG_DATA_HOME/prospero, else ~/.local/share/prosperoStandalone storage directory
--hostPROSPERO_HOSTlocalHost identity reported in fleet snapshots
--fleet-backendPROSPERO_FLEETlocallocal (caliband over Unix sockets) or k8s
--poll-interval-ms—2000How often the local backend polls each caliband
--automation-interval-ms—30000How often due automation schedules are checked. Cron resolves to whole minutes, so this bounds only how late a firing can be
--no-autostart—offDon't start caliband for a workspace that has none running
--caliband-bin—calibandcaliband 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

FlagEnvPurpose
--api-tokens-filePROSPERO_API_TOKENS_FILETokens file; when set, every non-open route needs a token
--session-key-filePROSPERO_SESSION_KEY_FILEDashboard session-cookie key, at least 32 bytes
--insecure-no-authPROSPERO_INSECURE_NO_AUTHServe unauthenticated on a non-loopback address
--cookie-securePROSPERO_COOKIE_SECUREAlways mark the session cookie Secure

The startup rules are:

  • Without tokens, prosperod refuses a non-loopback --addr unless given --insecure-no-auth.
  • --insecure-no-auth cannot 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 byno --database-url--database-url / PROSPERO_DATABASE_URL
Event store + workspace registrysqlite events.db in the data dirPostgres
Live event busin-processPostgres LISTEN/NOTIFY
Stream ownershipthis process owns every streamper-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:

FlagEnvDefaultPurpose
--replica-idPROSPERO_REPLICA_ID$HOSTNAMELease identity; must be unique per replica
--lease-ttl-secs—30A stream's owner must renew within this window
--heartbeat-interval-ms—a third of the TTLHow 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:

  1. --default-env values.
  2. The curated provider fields. provider becomes CALIBAN_PROVIDER. For anthropic, openai and google, base_url becomes ANTHROPIC_BASE_URL, OPENAI_BASE_URL or GEMINI_BASE_URL, and api_key_from_env fills ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY from the named variable in prosperod's own environment.
  3. The workspace's raw env map.

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.

FlagEnvDefaultPurpose
--kubeconfigKUBECONFIGin-cluster, then ambient kubeconfigAPI-server connection
—PROSPERO_K8S_NAMESPACEdefaultNamespace for CalibanTask and Workspace resources
--k8s-caliband-ca-filePROSPERO_K8S_CALIBAND_CA_FILEunsetEnables TLS to each agent pod's session plane
--k8s-caliband-token-filePROSPERO_K8S_CALIBAND_TOKEN_FILEunsetBearer token for the session plane (requires the CA file)
--k8s-caliband-server-namePROSPERO_K8S_CALIBAND_SERVER_NAMEcalibandName checked against the pod's TLS certificate (SNI)
—PROSPERO_INCLUDE_THINKINGoff1 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>"}:

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

Routes

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

HEAD is accepted wherever GET is.

Workspaces

POST /api/workspaces takes:

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

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

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

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

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

Spawning

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

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

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

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

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

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

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

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

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

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

The response is 201:

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

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

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

MCP

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

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

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

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

The fleet stream

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

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

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

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

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

Why an agent is in its state

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

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

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

Usage

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

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

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

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

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

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

Automations

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

POST /api/automations takes:

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

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

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

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

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

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

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

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

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

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

Events

Every observation becomes a FleetEvent:

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

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

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

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

Clients should ignore kind values they don't recognize.

The SSE stream

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

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

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

Securing the API

prosperod authenticates requests with named API tokens (ADR-0010).

Scopes

ScopeAllows
readevery GET: fleet, usage, workspaces, agent events and streams, metrics, automations and their runs
operateread + spawn, kill, respawn, input, end-input, remove agent; firing an automation by hand; the whole MCP endpoint
adminoperate + 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-file is required (openssl rand -base64 48 > session.key) so replicas share cookie signing.

Clients

  • CLI: PROSPERO_TOKEN / --token, or PROSPERO_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/amd64 and linux/arm64, with the k8s feature 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

VarPurposeImage default
PROSPERO_ADDRBind address0.0.0.0:7878
PROSPERO_DATA_DIRsqlite directory (standalone)/data
PROSPERO_DATABASE_URLWhen set, run clustered (Postgres)unset (standalone)
PROSPERO_REPLICA_IDLease identity; must be unique per replicaHOSTNAME
PROSPERO_HOSTFleet identitylocal
PROSPERO_FLEETFleet backend: local or k8slocal
RUST_LOGLog filterinfo
PROSPERO_API_TOKENS_FILETokens file (<name> <scope> sha256:<hex>); when set, every non-probe request needs a tokenunset
PROSPERO_SESSION_KEY_FILEDashboard session key, at least 32 bytes; required when clustered with tokensunset (random key per process)
PROSPERO_INSECURE_NO_AUTH1 serves without auth on a non-loopback bind, with a warning logged every minuteunset
PROSPERO_COOKIE_SECURE1 always marks the session cookie Secureunset (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):

ValueBackendStatus
local (default)LocalFleet: caliband over Unix socketsFully served.
k8sK8sFleet: 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/workspaces and PUT /api/workspaces/{name}/config answer 202 Accepted rather than 201/204.
  • A workspace's sources and named providers come from its config; root is ignored.
  • GET /api/capabilities reports "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 get and patch on calibantasks/status. The prospero Helm chart grants it with the k8s fleet 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 CalibanTask CRD 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:

FlagEnvPurpose
--k8s-caliband-ca-filePROSPERO_K8S_CALIBAND_CA_FILEPEM CA bundle trusting caliband's serving cert. When set, dials use TLS.
--k8s-caliband-token-filePROSPERO_K8S_CALIBAND_TOKEN_FILEFile holding the shared bearer token (contents trimmed). When set, dials present it.
--k8s-caliband-server-namePROSPERO_K8S_CALIBAND_SERVER_NAMESNI / 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/, where README.md and ../superpowers/specs/<spec>.md both resolve; copied into the book they did not, because README.md and template.md are deliberately not chapters and nothing ingests docs/superpowers/. mdBook rewrote both to pages it never built, so three links in ADR 0001 and the Source: link of seven ADRs (0002–0007 and 0010) served 404s. sync-adrs.sh now 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.md changes. sync-changelog.sh ingests it into the guide, but docs.yml was 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 covers ci.yml's path filter now also fails the build when a path a sync-*.sh script reads is missing from either of docs.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.yml skips every cargo step when a diff touches only docs/, *.md or LICENSE, but crates/api/tests/guide.rs include_str!s docs/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 in TEST_INPUT_DOCS and treats them as code; a genuinely docs-only change still skips the gate. The declaration is held to the source by crates/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 with dtolnay/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 the wasm32-unknown-unknown target, because the targets: input in ci.yml applies to the toolchain the action installs rather than the one this file selects. (#261)

  • caliban-contract pinned 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.rs is the only file that differs between the two contract versions, and prospero imports only from caliban_contract::wire. Note for anyone else downstream — the field CalibandLaunch.router_config kept 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/stream carries 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 SSE id: is a fleet cursor: the store's durable insertion order, the only total order a fleet has (per-agent seq cannot order two agents, since both start at 1). ?from=<cursor> resumes exclusively after it, ?from=now skips history, and an absent from replays 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 new Store methods (replay_fleet, latest_fleet_cursor) are held to identical semantics across JSONL, SQLite and Postgres by a shared conformance battery. Requires read. The dashboard's 5-second poll is gone, replaced by the stream plus a 30-second reconcile so a dropped EventSource cannot freeze the screen. (#219) (#247)

  • The fleet over MCP. /mcp serves 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 same FleetProvider seam 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 returns truncated + 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. Requires operate; 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 same prospero-types structs the handlers serialize, via schemars, behind an off-by-default openapi feature 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 in x-prospero-scope. The spec cannot quietly fall behind the router — a test reads lib.rs for 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 to supervised everywhere, fail-closed) is now carried on the spawn body, the CLI (--permission-posture) and the CalibanTask. unattended requires an admin scope — 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's agentPolicy. Every agent reports the posture it runs under, and unattended agents carry a bypass tag in the dashboard. Also wires spec.isolation.worktrees through to SpawnSpec.isolation_worktree. (#238, #226) (#240)

  • Per-spawn wall-clock limit. timeout_secs on 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 writes DeadlineSet to 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 an AgentTimedOut event is emitted before the kill, so history says why the agent stopped instead of leaving a bare killed that reads like an operator did it. /api/usage counts it as timed_out alongside killed, and the dashboard gains the facet. Deliberately no AgentStatus::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.actor is 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 one operate token and serves a whole Discord, so every agent it spawned read actor: "ariel" whoever asked for it. A request may now carry X-Prospero-On-Behalf-Of, and the events it emits record it as on_behalf_of beside the unchanged actor. 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 it asserted_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/usage was 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 / 2w are 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 as 24h is refused with a reason, because usage is bucketed per UTC day and an hour would promise a precision the report lacks. --workspace filters client-side on the raw JSON, so --json keeps fields a newer server adds and both output modes show the same rows. (#223) (#254)

Fixed

  • GET /api/usage validates and normalizes its window. Three bugs, each reproduced by a test that failed first. A large days panicked the handler — ?days=200000000 was enough, chrono overflowing on the subtraction rather than inside Duration::days as the ticket guessed. since and until were never parsed, so ?since=yesterday returned 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 a Z bound 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 (400 bad_request on failure) and re-rendered, and days is checked against a shared cap. (#255) (#257)

  • One Drained agent no longer drops every agent on its caliband. wire.rs hand-mirrored caliband's control-plane types, and the mirror had already drifted into a live bug: caliban's AgentStatus gained Drained that the mirror never learned, and with no catch-all a single drained agent failed deserialization of the entire Listed reply, dropping every agent on that caliband from the poll. Those types are now re-exported from caliban's published caliban-contract crate (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. Drained maps to Done: terminal, not a failure. (#239) (#245)

  • The operator's failure reason reaches the agent. caliban-operator v0.6.0 refuses an unattended task whose Workspace does not allow it — phase Failed, Ready=False, reason PostureNotPermitted — 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 to failed and dropped the rest, leaving an unexplained dead agent and someone waiting for a sandbox that was never coming. Agent gains optional reason and detail, omitted when unset, filled from the operator's Ready condition and only when it is not True. 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 ToolCallEnd since caliban#391, but prospero's normalizer read only tool_use_id and is_error, so the inspector still said the result "needs a caliban-side change". EventKind::ToolFinished gains result and truncated, 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 AgentDriver seam 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 openssl example, 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 verbs lib.rs registers — so a route can no longer ship undocumented. (#256) (#258)

Internal

  • async-trait bumped to 0.1.92. Up to 0.1.89 the macro put #[must_use] on the method it desugars an async fn into, and since that method returns a future — already #[must_use] — clippy 1.99 reports double_must_use on macro-generated code, failing the lint gate for all seven #[async_trait] traits in prospero-core. Nothing in the repo changed to cause it: CI resolves stable at run time, so the build broke when stable moved from 1.97 to 1.99. The upstream bump is the fix rather than an allow.

0.8.1 - 2026-09-16

Fixed

  • The AgentsSettled condition is written in a real cluster. v0.8.0's condition carried lastTransitionTime, which the CalibanTask CRD'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 reached Completed, and prosperod logged the failure on every poll. prospero now sends only type, status and reason, matching caliban-operator's Condition, 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 scope read, operate or admin, stored as SHA-256 hashes in a tokens file; a route-scope table that fails closed to admin; JSON 401/403; and one prospero_audit log line per mutation. With tokens configured, every request needs one, loopback included. /healthz, /readyz, the dashboard shell and /api/session stay open. The dashboard signs in with a token for a 12-hour HMAC-signed HttpOnly; SameSite=Strict cookie bound to the token's hash, and cookie-authenticated mutations must be same-origin. Clustered prosperod with tokens needs --session-key-file so replicas share cookie signing. The token name is recorded as actor on 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> and prospero whoami. (#2) (#227)

  • AgentsSettled condition on CalibanTask status. After each poll in which a pod's caliband answers, prosperod server-side applies one condition 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 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 RBAC patch on calibantasks/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_THINKING set, 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 CalibanTask left 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 dedicated ollama env-projection arm are gone. validate_provider_env now treats a provider with an overridden base_url as keyless — mirroring caliban's keyless local-endpoint change — so a migrated local config (openai + base_url, no key) keeps the keyless UX that ollama had, instead of being rejected for a missing OPENAI_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 /v1 in #191, is deleted along with its embedded assets and routes. /v1 and /v1/app.js now 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 /v2 alias, 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 /v2 now 404s. Internally the same rename: crates/api/dashboard-v2/ is crates/api/dashboard/, the handler module is dashboard, and the CI job is "dashboard wasm build". (#204)

Added

  • DistributedBus::subscribe_all_ready — an unfiltered subscription whose LISTEN is 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 and LISTEN failures 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_all delivery 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 establishes LISTEN first and asserts on delivery rather than on patience — about a second, including under the coverage instrumentation the flake was worst under. (#132)

Security

  • anyhow bumped to 1.0.104, clearing RUSTSEC-2026-0190 (unsoundness in Error::downcast_mut()). Lockfile only; nothing here calls downcast_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 to wasm32-unknown-unknown and embedded in prosperod, 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 and include_bytes!'d, so an ordinary cargo build still 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/workspaces was 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 + 1 and 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 after AgentFinished reads 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 ToolCallEnd carries 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, or days). 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 under default-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 "whatever prefers-color-scheme says". 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-types and now carry both Serialize and Deserialize. They previously lived in prospero-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; /v2 stays 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 /v2 so / stayed untouched while epic #95 landed — that transition is complete (#191).

Deprecated

  • The v1 dashboard has moved to /v1 and is deprecated. It renders a notice pointing at /, and its script now lives at /v1/app.js (GET /app.js is 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 past Running — CRs whose agents finished a day earlier still read Running, 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 overlay snapshot() 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/fleet did 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 counts done/failed/killed/crashed from persisted status_changed events. 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 ProviderInfo did 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 a ProviderError against localhost (#188).

  • k8s-spawned agents get the resolved provider and model. spawn_spec_from_task sent provider/model as None on the premise that pod env would drive selection; the caliban worker selects from SpawnSpec and nothing else, so every k8s agent fell back to caliban's default and died at preflight with ANTHROPIC_API_KEY is not set. The operator already pins the resolved provider into status.resolvedWorkspace, so its kind and model are projected onto the spawn. ensure_pod_agent also 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 CalibanTask name is derived from the spec, so an identical prompt resolves to the run already in flight. POST /api/workspaces/{name}/agents now reports created, 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's interactive: true now survives the CR round-trip: build_calibantask writes spec.task.interactive (the field caliban-operator#28 added to the authoritative CRD) and spawn_spec_from_task reads it back into the SpawnSpec sent to the pod's caliband. Previously the flag was silently dropped at the CR boundary and the spawn spec hardcoded interactive: false, so a k8s agent could never await input and the dashboard reply box (interactive && idle) could never appear. Requires the caliban-crds chart 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_input still attached by CR name (404, reply lost) and overlay_pod_status still looked records up by id (silent miss, regressing #130's reply box). send_input now resolves the pod's agent via ensure_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 CalibanTask CR and a caliband pod but never started the LLM run — caliband is a passive supervisor that begins an agent only on CtlRequest::Spawn, and prospero's session plane only attached, so the attach looped forever on agent not found. prospero now spawns the agent in the pod's caliband (list-or-spawn, one agent per pod) from the CalibanTask prompt, 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 /stream expects (#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_agent applied the CalibanTask CR and then synchronously polled (up to ~30s) for status.phase == "Running" before returning, coupling the HTTP response to the full CR → operator reconcile → pod schedule → Running chain (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 reaches Running — 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 interactive and idle, and under PROSPERO_FLEET=k8s neither was sourced correctly — the status/interactive fields are now read from the pod's caliband rather than the CalibanTask CR 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 CalibanTask no longer wedges the whole fleet. K8sFleet's watch/readiness path listed CalibanTasks strictly, so one CR that failed to deserialize (e.g. a stale task predating the now-required workspaceRef field) failed the entire poll — the fleet never populated, /readyz stuck at 503, and the pod never became Ready. The list is now decoded per-item, skipping and logging the bad CRs (#148) (#152).
  • The k8s fleet snapshot reconciles with the Workspace registry. GET /api/fleet synthesized a single phantom k8s workspace and never read the registered Workspace CRs, so a registered workspace was invisible in the dashboard while the synthetic k8s entry reported workspace not registered. The snapshot now surfaces the registered Workspace CRs (agents grouped by the workspace they reference), so /api/fleet and /api/workspaces agree and a fresh deploy shows no phantom (#149, #151) (#153).
  • Add-workspace rejects an invalid workspace as 400, not a raw apiserver 422. The dashboard's + add workspace posted a Workspace with empty providers/sources, which the CRD (minItems: 1 on 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, K8sFleet now wires a FleetAdmin over operator-owned Workspace custom resources, so POST / PUT / DELETE on /api/workspaces persist and manage real configuration — multi-source workspaces, a named-provider list, and per-provider credentials referenced by Kubernetes Secret name (prospero never reads the Secret) — instead of returning 405. A backend-neutral WorkspaceConfig DTO lets one API serve both backends (local projects its single-provider subset, unchanged); GET /api/workspaces returns the real Workspace CRs with reconciliation status; async workspace writes answer 202 Accepted; and a spawned agent binds a named provider via providerRef (#142) (#144, #145).
  • Backend-aware dashboard. The dashboard fetches GET /api/capabilities and adapts. On k8s it renders a workspace editor (git sources + a named-provider list with secretName / key Secret references and a default marker), reconciliation status pills (pending / reconciling / ready / failed with 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 CalibanTask CRD mirror moved from an inline workspace to a workspaceRef (plus an operator-pinned status.resolvedWorkspace), matching caliban-operator's frozen v1alpha1 contract. 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 K8sFleet backend. 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+ prosperod replicas exactly one replica owns, attaches to, and emits each agent (no more duplicate SSE events or racing per-stream seq allocation). Standalone is unchanged (SelfOwnsAll); a clustered deploy builds a LeasedOwnership lease plus heartbeat. Attach tasks are now promptly torn down on stop/remove/restart, and any agent observed Running — 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-types crate — the normalized FleetEvent/model DTOs extracted into a small, wasm-compatible serde-only crate the WASM dashboard can share (#98) (#100).

Changed

  • Under PROSPERO_FLEET=k8s, prosperod no longer builds a local FleetManager/poll loop; the k8s backend serves directly over the shared store/bus (#83) (#92).
  • /readyz now reports workspaces_total/workspaces_healthy/ workspaces_unreachable (was repos_*), 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/fail instead of showing "running" forever (paired by tool_use_id) (#106) (#131); the fleet summary shows the workspace count, the misleading $0.0000 cost is gone, and a favicon is served (#115, #109, #119) (#134).
  • API. Duplicate workspace registration returns 409 Conflict, not a misleading 503 (#111) (#139); an unknown agent's events endpoint returns 404 instead of 200 [] (#118) (#135); api_key_from_env on a keyless provider is rejected at config-set time, and rm no 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 CalibanTask phases map to a terminal state, calibandEndpoint is validated, lock poisoning can't wedge the fleet view, the token compare is constant-time, and --fleet-backend k8s on a non-k8s build fails before any side effects (#114, #121, #125, #126, #127) (#136).
  • Tests. De-flaked the distributed_bus PG suite under parallel shared-DB load (#110) (#129) and cli_drives_the_full_stack (#85) (#94).

0.1.1 - 2026-07-05

Fixed

  • The released image now builds prosperod with --features k8s, so the K8sFleet backend is compiled in and PROSPERO_FLEET=k8s works. 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 running prosperod (REST + SSE + dashboard on 7878); also tagged :latest and :sha-<commit>.
  • Helm chart charts/prospero in caliban-ai/helm-charts, rendering standalone (SQLite + PVC) or clustered (external Postgres, N replicas) from one topology value.

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 caliband manages 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

  1. 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 FleetProvider trait: Local drives an existing caliband over the wire; K8s realizes the same verbs declaratively as CRUD on CalibanTask custom resources. (ADR 0002, ADR 0008)

  2. Couple through the wire, nothing else. Caliban's NDJSON wire format (and, for k8s, the CalibanTask CRD'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)

  3. 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)

  4. Normalize caliban's frames into a stable internal type. Raw stream-json frames become FleetEvent / FleetSnapshot; consumers never see raw frames. The normalizer is forward-compatible — an unknown frame is skipped and logged, never fatal. (ADR 0003, ADR 0004)

  5. 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)

  6. 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)

  7. Layered, one-directional crate boundaries. cli / daemon → api → core, acyclic; no web framework leaks into core'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)

  8. Abstraction behind traits for deferred evolution. Persistence sits behind a Store trait (realized as jsonl / sqlite / Postgres); fleet control sits behind FleetProvider (Local / K8s). Traits are the vehicle for scale-out and workspace scoping without touching call sites. (ADR 0004, ADR 0008)

  9. 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 to admin. 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 — and core'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 CalibanTask field 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 FleetProvider verbs 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. actor is the token prosperod authenticated. on_behalf_of is 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. LocalFleet drives a caliband over Unix sockets; K8sFleet realizes the same FleetProvider verbs as CRUD + watch on CalibanTask CRs, which the caliban-operator reconciles into sandboxed caliband pods. Both emit to the shared Store/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 / Ownership seams. (ADR 0004)

License

Prospero is licensed AGPL-3.0-only. (ADR 0009)

Architecture Decision Records

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.md gives 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

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 caliband per 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

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-contract crate (#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

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 List on 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 FleetSnapshot and a normalized FleetEvent type, and also append every event to durable storage behind a Store trait. The first implementation is JsonlStore (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 seq survives prosperod restarts. Streaming cost is bounded to active/watched agents rather than the whole fleet. Putting persistence behind a Store trait keeps the door open for a sqlite backend later without touching the rest of the system; JsonlStore is deliberately the simple first step.
  • Negative: durability is best-effort relative to liveness — a failed Store.append is 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 JsonlStore outgrows append-only files — either would promote the sqlite backend the Store trait was designed to allow.

ADR 0005 · Worktree isolation by default for agent spawns

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

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) over FleetManager; depends only on core.
  • prospero-daemon (prosperod) — process entry: owns the tokio runtime, config, logging, shutdown; wires core + api into a server.
  • prospero-cli (prospero) — a thin client that talks to prosperod over 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 api is testable in-process over a fake-backed FleetManager. 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

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 seq recovery 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 CalibanTask type and a kube client to CRUD/watch it;
  • the four FleetProvider methods 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

  1. Mirror a minimal CalibanTask type; 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 minimal kube::CustomResource (caliban.caliban-ai.dev/v1alpha1) in prospero-core carrying only the fields K8sFleet sets (workspace.sources, task.prompt, optional isolation) 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.

  2. K8sFleet implements FleetProvider over CalibanTask CRs. New prospero-core module behind a k8s cargo feature (so LocalFleet-only builds pull no kube).

    • ensure_agent(spec) → server-side-apply a CalibanTask (deterministic name from a hash of the spec, so it is idempotent); await status.phase = Running + status.calibandEndpoint; return AgentHandle { endpoint: Endpoint::Tcp(calibandEndpoint) }.
    • watch_fleet() → a kube::runtime::watcher on CalibanTask → translate applied/deleted + phase transitions into FleetChange::{Discovered,StatusChanged,Gone} (map Phase → AgentStatus), seeded by an initial list.
    • stop_agent(id, drain) → delete the CalibanTask (the operator's owner-ref GC tears down the Sandbox); Graceful best-effort awaits deletion within the timeout.
    • restart_agent(id) → delete + re-apply (fresh name → fresh id).
  3. The session plane dials the agent Endpoint over #75's transport and feeds the existing bus + store. K8sFleet carries its own attach task built on CalibandClient::connect_tcp + the shared stream normalizer + Emitter, so /stream SSE 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 of FleetManager into a provider-agnostic helper if cheaper than duplicating.

  4. Generalize the conformance suite behind a FakeBackend trait. Replace fleet_provider_conformance(provider, fake: &FakeCaliband) with (provider, backend: &dyn FakeBackend) where FakeBackend { received_any_spec(); simulate_reap(id) }. FakeCaliband implements it trivially (its existing received_specs/remove_agent); a new in-memory FakeK8s implements it for K8sFleet. LocalFleet's existing conformance run is unchanged. This keeps ADR 0007's "test the control plane against a fake" property for both backends.

  5. Backend selection at the daemon edge. prosperod chooses LocalFleet vs K8sFleet by config/env (mirroring caliban's --database-url topology switch — e.g. PROSPERO_FLEET=local|k8s + namespace/kubeconfig). The API layer's remaining direct FleetManager calls (kill/respawn/steer/snapshot) are an ADR 0006 P1 limitation tracked separately; K8sFleet MVP wires the four provider methods + the session plane.

Consequences

  • prospero gains a Kubernetes fleet backend — kubectl-less fleet control via CalibanTask CRs, 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-openapi dependency, scoped to prospero-core behind the k8s feature; LocalFleet builds and runs with no cluster.
  • A second mirrored seam (the CalibanTask type 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 FleetManager calls 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

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-forward and sidecars arrive as loopback, so that rule would bypass scopes.
  • Browser: EventSource cannot send an Authorization header, 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 with prospero 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-auth is set. /healthz and /readyz are always open.
  • The dashboard signs in with a token and receives a stateless HMAC-signed, HttpOnly; SameSite=Strict session 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 the FleetEvent envelope where the mutation records an event (local-fleet spawn and remove), otherwise in an info log 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 Ask with no human attached is simply denied, and the refusal reaches nobody. ACP models both first class — tool_call_update carries result content, and session/request_permission asks 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:

  1. The worker's ACP mode is a fork, not an addition. caliban/src/worker.rs returns into run_acp_worker before 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.
  2. 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 each session/prompt blocks for one turn and returns a stopReason. Under ACP, prosperod is not an observer — it is the thing running the agent, and if it stops calling, the agent does nothing.
  3. Parity gaps are still open (caliban#674). ACP's tool_call carries no input, TurnEnd/RunEnd are dropped (no usage, cost or turn counts), and loadSession is unwired. Adopting ACP for caliban agents today would regress the tool inspector (#236 ships input and result) and empty /api/usage.
  4. 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 FleetProvider that spawns claude-code-acp/codex-acp/gemini --acp itself 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 AcpDriver drive a non-caliban agent unchanged.
  • For the caliband control/NDJSON half, the hand-mirror is replaced by the published caliban-contract crate (#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 was caliban-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:

ACPEventKindNote
agent_message_chunkOutput { stream: Stdout, chunk }
agent_thought_chunkOutput { stream: Thinking, chunk }
tool_callToolStarted { id, name, input }input is Null until caliban#674
tool_call_updateToolFinished { id, ok, result, truncated }result from content, capped by TOOL_RESULT_CAP (#236)
session/prompt returns stopReasonAgentFinished { outcome, cost_usd: 0.0, turns }usage/turns are 0 until caliban#674
session/request_permission(new) pending-decision state on the agentanswered over the same connection
session/new resultAgentInit { 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 AgentDriver seam 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 until loadSession exists (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 AgentDriver seam is the wrong abstraction and belongs one layer up, at FleetProvider.