Introduction
Pressure wakes agents — multiplexer, dashboard, and episode flywheel in one binary.
Castellan is the coordination runtime for multi-agent AI systems. Agents deposit into a typed blackboard and a decaying pheromone field; a pressure scheduler wakes the next agent when dependency signals clear. That is stigmergy — coordination through the environment — and it replaces a chat-router tax with geometry you can log, replay, and evolve.
- Pressure wake — deterministic scheduling from substrate signals, not a manager model picking the next agent.
- Mux + dashboard — panes and the operator UI in one binary.
- Evolve + drift — episodes improve topology; gates keep changes honest.
- RAH depth-bounded — harness-in-harness recursion with topology binding, not opaque shell-outs.
Episode flywheel
flowchart LR
RUN[castellan run] --> LOG[.castellan/episodes]
LOG --> EVOLVE[castellan evolve]
EVOLVE --> GATE[castellan drift-check]
GATE --> RUN
classDef runtime stroke:#0969da,stroke-width:2px
classDef evolve stroke:#d97706,stroke-width:2px
classDef gate stroke:#1a7f37,stroke-width:2px
class RUN,LOG runtime
class EVOLVE evolve
class GATE gate
- Run —
castellan run(add--mux/--rlmwhen you want panes) - Log — episode JSON under
.castellan/episodes/ - Evolve — topology mutations from the corpus
- Gate —
castellan drift-checkagainst the verification manifest
For agents
Paste this into an agent session before it drives Castellan:
Read https://castellan-docs.pages.dev/integrations/agent-guide.html
(or the mirrored agent-guide.md). Operate via multiplexer panes + substrate
tools — not agent-to-agent chat. Prefer castellan herd status and MCP tools.
Full conventions: Agent guide. Lexicon: Conventions.
Install
Get the castellan binary on your machine and confirm the verify trio passes.
Install methods
| Method | Command | Best for |
|---|---|---|
| Install script | curl -fsSL https://raw.githubusercontent.com/Alphabetsoup16/Castellan/main/install.sh | bash | Quick try / operators |
| From clone | git clone … && ./install.sh | Development |
| Cargo path | cargo install --path crates/castellan-cli | Rust contributors |
| Cargo release build | cargo build --release -p castellan-cli | CI / local dev |
Requires stable Rust from rustup.rs; toolchain pinned in rust-toolchain.toml.
After install script or release build, ensure castellan is on PATH:
export PATH="$PWD/target/release:$PATH" # from repo root
Verify trio
Run these three commands after install — all should succeed:
castellan --version
castellan run --goal "reach target" --plugin gridworld
castellan doctor
Episode JSON appears under .castellan/episodes/. For full CI parity, see Verify and ./scripts/verify.sh.
Optional: mdbook (docs contributors)
cargo install mdbook mdbook-mermaid --locked
mdbook build book
Next steps
| Goal | Page |
|---|---|
| First product win (mux + dashboard) | Quickstart |
| Day-to-day operator loop | Daily driver |
| Agent onboarding | Agent guide |
| Contributor PR gate | Verify before PR |
Quickstart
Install Castellan, bring up the multiplexer and dashboard, then land a first episode — under ten minutes when install works.
What you get
Binary on PATH → mux socket → dashboard → first episode JSON.
See it first
Offline / no CDN — static transcript
$ castellan plugin list
$ castellan run --goal "reach target" --plugin gridworld
episode satisfied
1. Install
curl -fsSL https://raw.githubusercontent.com/Alphabetsoup16/Castellan/main/install.sh | bash
castellan --version
From source: Install.
2. Mux + dashboard
castellan multiplexer ensure
castellan dashboard
Defaults:
- Socket:
~/.config/castellan/castellan.sock - Episodes:
.castellan/episodes/ - Config:
castellan.tomlwhen present
In another terminal:
castellan herd status
Quit the dashboard with q. Keybindings: Dashboard.
3. First episode through the mux
castellan run --goal "reach target" --plugin gridworld --mux
Episode JSON lands in .castellan/episodes/<goal-id>.json.
When an LLM/editor is wired, swap in a real goal — for example castellan run --goal "…" --plugin shell --mcp. See MCP overview and Agent guide.
Optional: offline smoke (no mux)
castellan run --goal "reach target" --plugin gridworld
Common commands
Next steps
| Goal | Page |
|---|---|
| Day-to-day operator loop | Daily driver |
| Dashboard keybindings | Dashboard |
| MCP tool catalog | MCP tools |
| Wire Cursor / editors | Cursor setup |
| Remote / SSH sessions | CLI — remote |
| Contributor verify before PR | Verify |
| Vocabulary | Conventions |
Daily driver
Recommended local workflow for operating Castellan — agents, panes, and the episode flywheel. For contributing to the Rust codebase, see Engineering guide and Verify before PR.
Why this loop
Mux keeps panes alive, the dashboard shows pressure and agents, run writes episodes, evolve mutates topology, drift-check gates what you trust.
Loop
castellan multiplexer ensure → castellan dashboard → castellan run → castellan evolve → castellan drift-check
- Multiplexer —
castellan multiplexer ensurestarts the in-tree PTY server (~/.config/castellan/castellan.sock). Idempotent; safe at the start of every session. Use--session <name>for an isolated socket. - Dashboard —
castellan dashboardopens the ratatui UI (agents, pressure, pane tree). Writes~/.config/castellan/statusline.jsonfor MCP observability. Keys:j/kselect,Enterattach,rrefresh,qquit — see Dashboard. - Run — pick a mode:
| You want | Command |
|---|---|
| Scheduler-led panes (daily default) | castellan run --goal "…" --plugin gridworld --mux |
| Offline / CI smoke | castellan run --goal "…" --plugin gridworld |
| Headless verify/act | castellan run --goal "…" --plugin gridworld --rlm |
| LLM/editor wired | castellan run --goal "…" --plugin shell --mcp |
Every mode writes .castellan/episodes/<goal-id>.json. Default castellan run is a deterministic plugin loop — add --mcp / --model only when you want LLM enrichment.
- Detach / reattach — panes persist without babysitting.
Ctrl+Qdetaches; latercastellan herd tabs/castellan herd attach <pane-or-session>. - Evolve —
castellan evolve --episodes 16 --workspace .castellan/episodes. Prefer--plan/--dry-runbefore applying; MCP exposescastellan_propose_mutation/castellan_resolve_mutation. - Gate —
castellan drift-checkagainst the verification manifest before you trust a topology change.
Honest scope:
drift-checkcatches structurally invalid or manifest-violating topology. It does not grade goal quality — use episode verdicts andcastellan vitalsfor that.
Config layers from castellan.toml when present. Episodes and archive live under .castellan/.
Operator commands
Remote / SSH
When something breaks
| Symptom | Likely cause | Fix |
|---|---|---|
castellan herd status errors | No mux server | castellan multiplexer ensure |
| Dashboard opens blank | Socket up, no panes yet | Run with --mux, then r |
Episode missing after run | Plugin exited early | Rerun with --json |
evolve accepts 0 | Corpus too small | More episodes, or --plan first |
drift-check rejects | Structural/manifest violation | Inspect violations; do not force-apply |
More symptoms: Troubleshooting. Staged diagnostics: castellan doctor --json.
Related
| Goal | Page |
|---|---|
| First-time setup | Quickstart |
| Dashboard keybindings | Dashboard |
| MCP / statusline | MCP overview · Statusline |
| Agent onboarding | Agent guide |
| Contributor PR gate | Verify |
| Lexicon | Conventions |
Troubleshooting
Common first-run failures and fixes. Prefer this page for symptoms; Daily driver for the full operator loop; Conventions for paths and lexicon.
What to check first
castellan --version— binary on PATHcastellan multiplexer ensurethencastellan herd status— mux upcastellan doctor— staged diagnostics
castellan: command not found
cargo install --path crates/castellan-cli
# or
cargo build -p castellan-cli && export PATH="$PWD/target/debug:$PATH"
Multiplexer socket missing
castellan multiplexer ensure
# or
castellan herd server
Override socket path: export CASTELLAN_SOCKET=~/.config/castellan/castellan.sock
Episodes / doctor look in the wrong directory
Workspace data defaults resolve via resolve_workspace_data_dir (.castellan/ or legacy .flock/ when present). Override with:
export CASTELLAN_DATA_DIR=/path/to/data
Relative values are resolved against the workspace root (doctor --workspace / process cwd for run/evolve).
drift-check fails locally
./scripts/drift-guard.sh
cargo fmt --all
cargo clippy --workspace --all-targets -- -D warnings
Guardrail edits require manifest updates — see governance policies.
castellan run exits immediately with plugin error
castellan plugin list
castellan plugin info gridworld
Confirm --plugin name matches registry entry. Shell plugin requires bounded argv — no shell injection.
Dashboard shows disconnected
- Start multiplexer:
castellan herd server - Attach session:
castellan herd attach - Check
~/.config/castellan/statusline.json— see statusline
--json output not parseable
castellan run --json emits one CastellanEvent per line (NDJSON). Do not mix with pretty-printed episode JSON — episode file is written to .castellan/episodes/ separately.
Docs build fails
cargo install mdbook mdbook-mermaid --locked
./scripts/install-mermaid-assets.sh
mdbook build book
Related
What Castellan is / is not
Castellan is
- A coordination runtime — pressure-field scheduler, typed blackboard, episode logs
- A plugin harness boundary —
CastellanPlugin::verify_goaldrives the defaultcastellan runloop - An in-tree multiplexer — NDJSON socket API + PTY panes (MUX)
- A topology evolution loop —
castellan evolvemutates wake maps from episodes - Governance-aware —
castellan.tomlhooks, tool pipeline, drift-check manifest
Default castellan run uses deterministic plugins (gridworld, shell). The scheduler wakes agents from substrate signals — not a manager LLM.
Castellan is not
- A drop-in coding agent with dozens of model providers on the hot path
- A wrapper around external harness binaries — Castellan is the coordination runtime
- A meta-harness composition layer that shells out to opaque coding agents as black boxes
- A workflow DSL — coordination is the pressure field, not hand-drawn graphs
- A chat router — agent-to-agent NL handoffs are intentionally forbidden
Honest scope: Castellan optimizes multi-agent coordination geometry — scheduler ticks, pheromone deposits, topology evolution, and multiplexer panes. Vocabulary: Conventions. Landscape peers (for maintainers only): Frontier positioning.
Comparison snapshot
| Need | Castellan | Typical coding agent |
|---|---|---|
| Daily coding agent | Use castellan acp or editor + MCP | Primary product |
| Multi-pane agents | In-tree multiplexer + dashboard | External tmux scripts |
| Topology evolution | castellan evolve + archive | Usually out of scope |
| Stigmergy / pressure field | Core scheduler | Usually out of scope |
| Provider matrix | Optional verify-path LLM only | Often hot-path feature |
Honest adoption path
| Need | Use |
|---|---|
| Prove coordination thesis | castellan run, swarm-demo, meeting-sched benches in verify.sh |
| Editor integration | castellan acp (stdio + prompt → engine) |
| External tool bridge | castellan mcp or castellan run --mcp |
| Remote mux panes | castellan remote --plugin <name> with governance |
| Cursor / Claude daily driver | Pair Castellan multiplexer with agent hooks |
Castellan deliberately does not chase coding-agent feature parity — coordination runtime and honest scope are the product.
Related
Agent guide
Outcome: onboard an LLM or editor agent so it drives panes and substrate tools — not agent-to-agent chat.
Onboarding for AI agents operating Castellan panes — Claude Code, Codex, Cursor, and Pi. Paste this page (or llms.txt) into your agent context before driving Castellan.
What you are operating
Castellan is a coordination runtime, not a chat router. Agents coordinate through:
- Blackboard — typed JSON slots
- Pheromone field — decaying zone signals that wake the scheduler
- Multiplexer panes — PTY sessions with NDJSON socket control
Do not coordinate via agent-to-agent natural language. Use substrate tools and pane deposits.
Install (operator machine)
curl -fsSL https://raw.githubusercontent.com/Alphabetsoup16/Castellan/main/install.sh | bash
export PATH="$HOME/.cargo/bin:$PATH" # if install.sh used cargo install
Verify:
castellan run --goal "reach target" --plugin gridworld
ls .castellan/episodes/
Daily-driver loop
castellan multiplexer ensure # start socket server
castellan dashboard # ratatui dashboard (optional)
castellan herd status # agent states across panes
castellan run --goal "..." --plugin shell --mcp
Episode flywheel: castellan run → .castellan/episodes/*.json → castellan evolve → castellan drift-check.
Agent-specific pane setup
| Agent | Start in pane | Hook install |
|---|---|---|
| Claude Code | claude in pane | castellan integration install claude |
| Codex | codex in pane | castellan integration install codex |
| Cursor | Cursor terminal or cursor CLI | MCP via castellan mcp (see below) |
| Pi | pi in pane | Use Pi programmatic/RPC mode in pane |
Spawn via socket API:
{"id": 1, "method": "agent.start", "params": {"name": "claude", "focus": true}}
Or CLI: castellan herd attach --pane <id> after castellan herd status.
MCP workflow (Cursor and headless agents)
- Operator wires
castellan mcpin MCP settings (Cursor setup). - Agent calls
castellan_search_tools→castellan_describe_tool→ invoke. - For live substrate during episodes, operator runs
castellan run --mcp.
Key tools: castellan_deposit_signal, castellan_read_blackboard, castellan_topology_snapshot, castellan_multiplexer_status, castellan_recall, castellan_remember. Always call castellan_discover_tools or castellan_search_tools before invoking a tool you haven’t used yet — do not guess tool names.
Full catalog: MCP tools · MCP overview.
Socket automation (Multiplexer-shaped)
Connect to ~/.config/castellan/castellan.sock. One JSON request per line.
{"id": 1, "method": "ping", "params": {}}
{"id": 2, "method": "agent.list", "params": {}}
{"id": 3, "method": "pane.read", "params": {"pane_id": "w1:p1", "lines": 40}}
{"id": 4, "method": "events.wait", "params": {"match_event": {"agent_status": "idle"}, "timeout_ms": 60000}}
Full method table: Socket API.
Diagnosis recipes
| Symptom | Check | Fix |
|---|---|---|
| No socket | castellan herd status fails | castellan multiplexer ensure |
| Agent stuck “working” | castellan herd agent explain --pane <id> | Wait or pane.report_agent hook |
| MCP tools missing | Cursor MCP panel | Restart Cursor; verify castellan mcp path |
| Episode not written | castellan run exit code | Run with --json; check plugin goal |
| Drift rejected | castellan drift-check output | Review castellan.toml governance section |
MCP tool call -32601/-32602 | Wrong tool name or missing required arg | castellan_describe_tool for the exact schema — never invent tool names |
Flags agents should know
| Command | When |
|---|---|
castellan run --json | Headless NDJSON contract (episode_start … episode_end) |
castellan run --mcp | Live MCP tools on running engine |
castellan run --rlm | RLM verify/act loop |
castellan remote --ssh user@host | Remote mux panes |
castellan evolve --episodes N | Topology mutation from corpus |
Do not invent flags — verify with castellan --help or CLI reference.
Skills
Castellan scans .castellan/skills/ and ~/.cursor/skills/ on castellan run. Author skills as SKILL.md with frontmatter; install via Cursor skills UI or copy into project.
Honest limits
- Castellan is not OpenCode/Claude Code — no built-in 75-provider coding agent on the hot path.
- Default
castellan runuses deterministic plugins; wire your LLM via MCP or panes. - Agent-to-agent chat coordination is forbidden by design.
See What Castellan is / is not.
Cursor setup
Outcome: wire Cursor as an operator — MCP tools for field/mux, pane agents for work.
Use Castellan in Cursor via MCP (castellan mcp). ACP is available for experiments (Zed-class editors) but MCP is the primary Cursor path.
Install
git clone https://github.com/Alphabetsoup16/Castellan.git && cd Castellan
./install.sh
export PATH="$PWD/target/release:$PATH"
Wire MCP in Cursor
Add a server entry to Cursor MCP config (Settings → MCP, or ~/.cursor/mcp.json):
{
"mcpServers": {
"castellan": {
"command": "/path/to/Castellan/target/release/castellan",
"args": ["mcp"]
}
}
}
Set command to your built target/release/castellan path — Cursor spawns this exact binary, so a relative path or an unbuilt release binary will fail silently in the MCP panel. Restart Cursor after editing the config; MCP servers are loaded once at startup.
Discovery flow: castellan_search_tools → castellan_describe_tool → invoke. Full catalog: MCP tools.
First smoke
Then in Cursor: open the MCP panel (Settings → MCP → castellan) and confirm the server shows connected with tools listed. Ask the agent to call castellan_discover_tools to verify the wire end-to-end.
MCP vs ACP
| Path | Cursor use |
|---|---|
castellan mcp | Primary — tool discovery, substrate read/write, same registry as live episodes |
castellan acp | Experimental — in-process prompt bridge, no MCP tool bridging in Cursor today |
Failure paths
| Symptom | Likely cause | Fix |
|---|---|---|
| Server shows “no tools” in MCP panel | Binary not built, or wrong command path | cargo build --release -p castellan-cli; verify path with which castellan or absolute path |
| Server never connects | Cursor cached a stale process | Restart Cursor fully (not just reload window) |
Tool call returns -32601 | Tool name typo or stale Cursor cache of tools/list | Re-run tools/list; check name against MCP tools |
| Agent invents a tool name | Model hallucination, not a Castellan bug | Instruct the agent to call castellan_discover_tools before guessing |
| Deposits rejected | Governance pre_execute hook denied | Check castellan.toml governance section; see Governance |
Skills and memory
Skills: .castellan/skills/ and ~/.cursor/skills/ are both scanned on castellan run.
Memory is the substrate (blackboard, field, episodes) — see Memory architecture. Vector RAG is deferred. Maintainer SQL schema and trait detail: MEMORY_ARCHITECTURE.md.
Agent onboarding
For paste-into-agent context: Agent guide.
MCP overview
Outcome: wire an editor or agent so it can deposit, read the field, and observe the mux — without inventing tool names.
Castellan exposes stigmergy, memory, and harness tools via castellan mcp (stdio JSON-RPC) and live episodes (castellan run --mcp). Both paths share one registry, so tool names, schemas, and governance behavior never drift between editor use and headless runs.
Same tools, two surfaces
castellan mcp
# or during a live episode:
castellan run --goal "…" --plugin shell --mcp
stdio JSON-RPC on stdin/stdout. Methods: initialize, tools/list, tools/call.
{"jsonrpc":"2.0","id":1,"method":"tools/list"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"castellan_multiplexer_status","arguments":{}}}
Same LIVE_TOOL_NAMES registry as the CLI path — discover → describe → invoke.
Start the server
castellan mcp
Wire your MCP client to stdin/stdout JSON-RPC. Same registry as live episodes — see crates/castellan-mcp/src/registry.rs.
Anatomy: discovery → describe → invoke
flowchart LR
A[Agent starts] --> B{castellan_discover_tools<br/>or castellan_search_tools}
B --> C[castellan_describe_tool]
C --> D[Invoke operational tool]
D --> E[Governance + trace]
classDef runtime stroke:#0969da,stroke-width:2px
classDef substrate stroke:#5b6b8c,stroke-width:2px
classDef gate stroke:#1a7f37,stroke-width:2px
class A,B runtime
class C,D substrate
class E gate
- Discover —
castellan_discover_toolsreturns domain-indexed cards (no full schemas). Optionaldomainfilter. - Search —
castellan_search_toolswithquerynarrows by intent; BM25-ranked, substring fallback for partial words. - Describe —
castellan_describe_toolwithnamereturns the full schema, preconditions, side effects, and examples. - Invoke —
tools/call(stdio) or themcp_toolsobservation payload (live episodes).
Meta-tools always ship full schemas in tools/list; operational tools ship compact cards to keep context small — call castellan_describe_tool before invoking one you haven’t used yet.
Full tool catalog
Verified against LIVE_TOOL_NAMES in crates/castellan-mcp/src/registry.rs — do not invent tool names; if a tool you need isn’t here, it doesn’t exist yet.
Meta tools
| Tool | Purpose |
|---|---|
castellan_discover_tools | Browse domains and tool names |
castellan_search_tools | BM25-ranked keyword search over the catalog |
castellan_describe_tool | Full schema + preconditions + side effects for one tool |
Operational tools
| Tool | Domain | Summary | Annotations |
|---|---|---|---|
castellan_deposit_signal | substrate | Deposit pheromone into a zone | destructive, governed pre/post |
castellan_read_signal | substrate | Read signal strength from a zone | read-only |
castellan_read_blackboard | substrate | Read a blackboard slot by key | read-only |
castellan_topology_snapshot | substrate | Harness topology JSON (agents, edges, zones) | read-only |
castellan_metrics | substrate | Session counters (tool calls, deposits, mutations) | read-only |
castellan_multiplexer_status | multiplexer | ObservabilitySnapshot + freshness (stale, age_secs, schema_version) | read-only |
castellan_propose_mutation | evolve | Preview a topology mutation (diff + drift); does not apply | preview-only |
castellan_resolve_mutation | evolve | Accept or reject a persisted proposal | destructive, high-impact gate |
castellan_read_resource | governance | Resolve a typed URI (episode://, genome://, pane://, blackboard://) | read-only, allowlisted schemes |
castellan_recall | memory | Query durable memory (episodes, checkpoint, observations, topology, traces) | read-only |
castellan_remember | memory | Persist an agent observation to durable memory | destructive |
castellan_query_field_history | memory | Query checkpoint-time pheromone field snapshots | read-only |
All 12 operational tools are in LIVE_TOOL_NAMES, so they work identically on castellan run --mcp.
Full schemas, examples, and design rationale: MCP tools reference.
Discovery sequence (copy-paste)
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"castellan_search_tools","arguments":{"query":"deposit","domain":"substrate"}}}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"castellan_describe_tool","arguments":{"name":"castellan_deposit_signal"}}}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"castellan_deposit_signal","arguments":{"zone":"grid","signal":"coordination","amount":2.0}}}
Offline / no CDN — static transcript
$ printf '{"jsonrpc":"2.0","id":1,"method":"initialize"}\n' | castellan mcp
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05",...}}
$ echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"castellan_search_tools","arguments":{"query":"deposit"}}}' | castellan mcp
{"jsonrpc":"2.0","id":2,"result":{"hits":[{"name":"castellan_deposit_signal",...}]}}
Live field instrument (castellan mcp --attach)
While castellan run is active it publishes a live session under .castellan/live/
(active.json pointer, per-goal snapshot JSON, and an append-only deposit queue).
The transport is a file bridge (not a Unix domain socket): the engine drains the
queue at tick boundaries—the same semantics a socket would provide, without an async
listener inside the sync run loop. External MCP clients hit the same field the
scheduler ticks:
castellan mcp --attach auto # bind to the active live session
castellan mcp --attach <goal_id> # bind to a specific run
castellan_deposit_signalenqueues into the live run; the engine drains the queue each tick, emitsCastellanEvent::Deposit, and records the deposit on the episode’sinstrument_summarywithsource: "mcp_attach".castellan_topology_snapshotreturns the last published live snapshot (tick, topology, field zones, wake stats).- Mutation tools (
castellan_propose_mutation,castellan_resolve_mutation) are denied in attach sessions (attach_deniederror). - If no live session exists,
--attachfails at startup (attach_unavailable) instead of silently degrading to a standalone engine. If the run ends while attached, calls fail withattach_lost.
Without --attach, plain castellan mcp still auto-routes deposits to an active
live session when one exists (source: "external_mcp"), falling back to its own
in-memory engine otherwise. castellan doctor reports the live_session stage.
Episode JSON records all MCP instrument activity:
"instrument_summary": {
"mcp_tool_calls": 3,
"mcp_deposits": [
{ "tick": 2, "zone": "grid", "signal": "coordination", "amount": 1.5, "source": "mcp_attach" }
],
"topology_snapshots": [{ "tick": 2, "nodes": 2, "edges": 1 }]
}
Live episode example (castellan run --mcp)
{
"mcp_tools": [
{
"tool": "castellan_deposit_signal",
"args": { "zone": "grid", "signal": "coordination", "amount": 2.0 }
}
]
}
Put entries under mcp_tools in a plugin’s observation payload; CastellanEngine::call_mcp_tool dispatches them against the same registry and governance pipeline as stdio.
Failure paths
| Symptom | Cause | Fix |
|---|---|---|
JSON-RPC -32601 | Unknown method or tool name | Check spelling against the table above; run tools/list to confirm |
JSON-RPC -32602 | Invalid params for a tool | Call castellan_describe_tool first — required fields are enumerated in inputSchema |
castellan_search_tools query rejected | Query over 256 characters | Shorten the query string |
Tool call silently no-ops on --mcp | Tool not in LIVE_TOOL_NAMES | All 12 operational tools currently are — check mcp_tools payload shape matches the JSON above |
castellan_propose_mutation never applies | Two-phase by design | Call castellan_resolve_mutation with accept: true and the returned proposal_id |
| Deposits rejected mid-episode | Governance pre_execute hook denied | Check castellan.toml governance section and --audit <PATH> JSONL |
Trace JSONL emits mcp_discovery for meta-tools and mcp_tool for operational calls — use --audit on castellan run to capture both.
Editor integration
- Cursor setup — wire MCP in Cursor settings
- Agent guide — onboarding for Claude/Codex/Cursor/Pi
castellan mcpCLI page — stdio recipes
Statusline and observability
Castellan exposes complementary observability surfaces that share one schema:
statusline.json—ObservabilitySnapshot(file + MCP-readable)castellan run --json— typedcastellan-eventsNDJSON streamcastellan telemetry --json— SQLite history (sessions, vitals, cost rollups) for CI/metrics
TUI (castellan dashboard) and headless (castellan statusline refresh) write the same snapshot — no parallel JSON schemas.
statusline.json
Default path: ~/.config/castellan/statusline.json.
| Writer | When |
|---|---|
castellan dashboard | Every ~250ms while the TUI is open |
castellan statusline refresh | One-shot (or --watch) without ratatui |
{
"schema_version": 1,
"written_at": "2026-07-18T04:00:00+00:00",
"connected": true,
"socket": "/Users/you/.config/castellan/castellan.sock",
"bus": { "socket": "up", "events": "live", "panes": "socket" },
"agents": { "total": 3, "idle": 1, "working": 1, "blocked": 0, "done": 1, "unknown": 0 },
"selected": { "pane_id": "w1:p1", "agent": "claude", "status": "working" },
"vitals": { "circulation_score": 0.82, "deferred_ratio": 0.1, "cold_zone_count": 0, "pane_pulse": 0.4 },
"genome": { "genome_id": "gen-abc123", "generation": 4, "lineage_depth": 2 },
"budget": { "mcp_headroom": 40, "zone_headroom": 12, "deferred_ratio": 0.1 },
"session_cost": { "usd": 0.42, "tokens": 18300 },
"live_pressure": { "pane": 0.55 },
"plan_verify": { "passed": false, "tool": "shell", "reason_code": "missing_plan" },
"bio": { "kind": "quorum_fired", "summary": "phase=commit ρ=0.80" },
"topology_apply": { "tick": 3, "born": 1, "pruned": 0, "wake_threshold": 0.4 },
"potential_delta": null,
"telemetry": { "agent_transitions": 42 },
"last_events": []
}
potential_delta (Φ) is omitted or null unless the wire exposes a real value — never a fake number. Footer shows Φ=— when unknown.
Dual-bus honesty (bus)
| Field | Meaning |
|---|---|
socket | up / down — mux Unix socket ping |
events | live / stale / idle — EventBus activity |
panes | socket — Enter→attach uses the socket pane tree (in-process --mux run panes are a separate host; events may still fan out on the dual bus) |
Read paths
| Consumer | How |
|---|---|
| MCP | castellan_multiplexer_status — returns schema_version, written_at, age_secs, stale, plus status body |
| Headless / CI | castellan statusline refresh --json then jq .schema_version |
| History DB | castellan telemetry --limit 20 (or dashboard --history) |
castellan multiplexer ensure
castellan statusline refresh --once --json | jq '{schema_version, connected, bus, live_pressure}'
# MCP freshness
# castellan_multiplexer_status → { ok, stale, age_secs, status }
castellan-events schema (--json)
castellan run --json emits one serde-tagged event per line. Canonical variants include:
| Event | When |
|---|---|
episode_start / episode_end | Session boundaries |
scheduler_tick / wake_summary / task_dispatched | Dispatch |
deposit | Pheromone deposit (drives dashboard field heat) |
cost_rollup | Per-tick usd/tokens + session totals |
plan_verify_passed / plan_verify_denied | Guardians prove-before-execute |
quorum_fired / zone_quarantine / morphogenesis / genome_expression_applied | Bio chips |
topology_apply_drained | Mid-run topology apply |
verify_result / tool_decision / permission_denied / budget_exhausted | Governance |
mutation_proposed / mutation_resolved | Evolve proposals |
archive_write_back | Post-run archive merge |
Multiplexer events.subscribe bridges the same schema via EmittedEvent::from_castellan_event (fields live under data).
Metrics JSON for CI
castellan telemetry --limit 20 # vitals_count, session_cost, recent_* arrays
castellan statusline refresh --json | jq '{schema_version, session_cost, live_pressure, plan_verify, bio}'
Honest gap: Thin HTTP/SSE of the same snapshot is not shipped yet — not a browser SPA. Prefer
statusline.jsonand MCP for headless observers.
Failure paths
| Symptom | Cause | Fix |
|---|---|---|
statusline.json missing | Never refreshed | castellan statusline refresh or open castellan dashboard |
vitals null | No episode vitals yet | castellan run --goal "..." --plugin gridworld |
MCP stale: true | File older than ~30s | Re-run statusline refresh or keep dashboard open |
bus.panes=socket but attach empty | Looking at in-process run panes | Attach uses socket tree; ensure panes on mux socket |
Related
ACP (Agent Client Protocol)
Castellan ships an ACP v1 stdio agent via castellan acp. Editors such as Zed can spawn Castellan as an external coordination agent — scheduler-primary runs that produce episodes, not chat transcripts.
MCP remains the primary Cursor integration path. See Cursor setup.
Capabilities
| Method | Status |
|---|---|
initialize | Protocol v1, camelCase agent capabilities |
session/new | Binds sessionId → castellan-memory acp_sessions |
session/fork | Fork session with parent lineage (parentSessionId persisted) |
session/prompt | Runs CastellanEngine + persist_episode_bundle; emits session/update stream |
session/resume | Restores checkpoint metadata (no chat replay) |
session/load | Replays episode metadata via session/update notifications |
session/cancel | Aborts in-flight prompt task |
session/close | Cancels + marks session closed |
permissions/request | Governance pipeline; returns needs_prompt for Prompt mode (editor UI). Set CASTELLAN_ACP_AUTO_DENY=1 in CI |
prompt | Deprecated alias for session/prompt |
Phase 3 (MOAT): mux-by-default when socket healthy; session/fork for parent-child lineage; EpisodeCheckpoint saved on every prompt.
Diagnostics
castellan acp doctor
Prints protocol version, agent capabilities, memory backend, mux health (mux_default_on, mux_effective, mux_socket_healthy), and env flags (CASTELLAN_MUX_AUTO, CASTELLAN_MUX_DISABLE, CASTELLAN_ACP_AUTO_DENY).
Zed smoke (5 steps)
- Build Castellan:
cargo build --release -p castellan-cli - Copy examples/zed-acp.json into your Zed settings agents list (adjust
commandpath). - Open a workspace with a
.castellan/directory (or let Castellan create one on first prompt). - Start the Castellan agent in Zed and send:
reach targetwith context plugingridworld. - Confirm the agent returns
stopReason: completed, ≥3session/updatenotifications, and.castellan/episodes/<goal_id>.jsonexists.
Mux episodes
Mux is the default when the multiplexer socket is healthy. Explicit "mux": true in prompt context forces mux; "mux": false opts out. Rollback env vars: CASTELLAN_MUX_AUTO=0 or CASTELLAN_MUX_DISABLE=1. ACP attaches pane_tree to persisted episodes for evolve flywheel compatibility.
Session fork
{"jsonrpc":"2.0","id":2,"method":"session/fork","params":{"sessionId":"<parent-id>"}}
Returns { "sessionId": "<child-id>", "parentSessionId": "<parent-id>" }. Child inherits cwd, plugin, and latest goal from parent.
Failure paths
| Symptom | Cause | Fix |
|---|---|---|
session/prompt hangs | No .castellan/ dir writable, or plugin missing | Run castellan doctor first; confirm --plugin name via castellan plugin list |
permissions/request always denied | CASTELLAN_ACP_AUTO_DENY=1 set (CI default) | Unset for interactive use, or handle the request explicitly in the editor |
Prompted MCP tools denied mid-session/prompt | Engine path uses headless prompter (fail closed) | Editor should approve via permissions/request, or set CASTELLAN_PERMISSION_PROMPT=allow for trusted CI, or mark tools allow in config |
session/load returns no messages | By design — episode metadata only, no chat replay | See Limitations below; use castellan replay --episode <path> for full JSON |
| Mux not attaching panes | Socket unhealthy | castellan acp doctor → check mux_socket_healthy; castellan multiplexer ensure |
Limitations
- No full chat transcript replay on
session/load— episode metadata only (MEMORY_ARCHITECTURE). - External editor MCP servers are bridged for
castellan_*tools only in Phase 2; shell/file tools remain editor-side. - crates.io publish for library crates is dry-run only until the API stabilizes.
Related
Coordination model
What
Castellan agents coordinate through a shared coordination field, not through conversation. A typed blackboard, decaying pheromone zones, and a pressure-driven scheduler decide who wakes and when — there is no router LLM picking the next agent, no natural-language handoff, and no agent-to-agent chat.
Why
Coordination geometry (topology, zones, wake thresholds) is the thing that gets tuned and evolved, not prompt text. Vocabulary: Conventions.
If you are wiring up a plugin, debugging why an agent never wakes, or trying to understand what castellan run --mux actually does under the hood, this page is the mental model you need.
Substrate primitives
| Primitive | Type | Role |
|---|---|---|
| Blackboard | Typed JSON slots | Structured read/write state, scoped per plugin manifest ([blackboard] slots = [...]) |
| Pheromone zones | PheromoneField | Named signal maps with exponential decay; agents perceive within a radius |
| Perception radius | Scalar per zone | Bounds how far an agent reads field signals — no global broadcast |
| Pressure field | PressureScheduler queue | Wakes tasks when dependency signals + base pressure clear a threshold |
| Completion signal | Deposit | NextAction::Complete deposits a completion signal into the zone; dependents wake |
| Quorum gate (H1) | QuorumSensor | Phase unlock when density ≥ Q and ≥ k distinct contributors deposit within window W — colony consensus without a router LLM |
| Trail corridors (H4/H9) | CorridorField | Optional (from,to,signal) edges with per-edge λ; Physarum conductance c ← c + α·flux − β·c prunes low-flow paths |
| Cement (H3) | cement signal | Successful verify deposits cement; morphogenesis may birth topology nodes under regulatory caps |
Wake pressure is computed as min(base_pressure, min(dependency signals)), optionally boosted by multiplexer pane activity (pane_status_wake_boost). A task with unmet dependencies stays deferred no matter how high its own base pressure is — the field, not an individual agent, decides readiness.
Quorum = colony phase gate. Unlike task wake (deps ∩ pressure), quorum locks a phase transition until multi-contributor density clears — ablate to a single depositor and the phase stays locked (FalseQuorum immune when mass lacks diversity).
Response thresholds (H2). Topology nodes carry private θ maps; among ready tasks the scheduler can prefer the node with highest stimulus−θ surplus — specialization emerges without hardcoded roles.
Allowed vs forbidden coordination patterns
| Pattern | Status | Why |
|---|---|---|
| Deposit completion → dependent wakes | Allowed | Core substrate mechanism |
| Blackboard slot handoff (typed JSON) | Allowed | Structured, auditable, replayable |
NextAction::Delegate { task } | Allowed | Explicit sub-task spawn, still substrate-mediated |
Multiplexer pane deposits (pheromone_deposits) | Allowed | Pane activity feeds the pane zone |
| Agent-to-agent chat | Forbidden | Re-introduces router-LLM coordination cost and non-determinism |
| Router LLM choosing next agent | Forbidden | Defeats the “coordination is geometry, not prompts” thesis |
| Re-encoding substrate state as natural language for another agent to parse | Forbidden | Loses structure, adds token cost, adds failure surface |
Anatomy: one scheduler tick
flowchart TB
G[Goal] --> P[encode_task]
P --> Q[Pending queue]
Q --> RP[refresh_pressure]
RP --> DEP{deps met?}
DEP -->|no| DEFER[deferred]
DEP -->|yes| DISPATCH[dispatch]
DISPATCH --> HOST[RunHost]
HOST --> OBS[observation]
OBS --> BB[Blackboard]
OBS --> FIELD[PheromoneField]
OBS --> V[verify_goal]
V --> COMP[deposit completion]
COMP --> FIELD
FIELD --> RP
classDef runtime stroke:#0969da,stroke-width:2px
classDef substrate stroke:#5b6b8c,stroke-width:2px
classDef gate stroke:#1a7f37,stroke-width:2px
class G,P,Q,RP,DISPATCH,HOST runtime
class OBS,BB,FIELD substrate
class V,COMP gate
CastellanEngine::run_goal walks this loop until the goal is satisfied, exhausted, or a step budget expires:
- Encode — the plugin’s
encode_task(goal)produces the initialTaskDescriptor. - Decay + refresh — the field decays one tick;
refresh_pressurerecomputes wake pressure for every pending task. - Dispatch — the ready batch (dependencies met, pressure over threshold) goes to the
RunHost(in-process, mux panes, or remote). - Observe — host output updates the blackboard and deposits into zone(s);
coordinate_from_observationapplies any explicit deposits from the plugin. - Verify —
verify_goalchecks the observation against the goal; on success it deposits acompletionsignal so dependents wake next tick. - Snapshot — field state, topology, and wake stats are written into the episode JSON under
.castellan/episodes/.
How operators use it
You rarely touch the substrate directly — you drive it through castellan run and read its effects back out of episode JSON and live MCP tools.
1. Run a goal and let the scheduler coordinate:
castellan run --goal "reach target" --plugin gridworld
2. Force scheduler-led multi-pane coordination (topology + pane tree recorded in the episode):
castellan run --goal "reach target" --plugin gridworld --mux
3. Inspect live substrate state while a run is active (via MCP tools, from an editor or castellan mcp client):
4. Replay a past run to see the coordination trace tick-by-tick:
castellan replay --episode .castellan/episodes/<goal-id>.json
5. Watch multiplexer pane activity feed the field — pane observations can carry pheromone_deposits:
{"pheromone_deposits": [{"zone": "pane", "signal": "activity", "amount": 0.5}]}
These deposits feed pane_status_wake_boost, which nudges wake pressure for stalled tasks whenever panes report activity — useful when a long-running shell pane is making progress but hasn’t hit a dependency signal yet.
Recipes
When a plugin has independent sub-tasks that must merge before completion, use NextAction::Delegate to spawn them and let the blackboard carry results back — don’t have the parent poll or message the children in natural language.
When you want a run to react to external pane activity (e.g. a human working in another tab), enable --mux so pane deposits land in the pane zone and boost wake pressure for dependent tasks.
When debugging “my task never wakes,” dump the episode JSON and check pending/deferred tasks against their declared dependencies — the field will not dispatch a task early no matter how urgent it looks to a human reader.
When you need deterministic, replayable coordination for CI, avoid --rlm/LLM-driven verify and stick to plugin-native verify_goal — the scheduler loop itself never calls an LLM.
Failure paths / troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Task stuck in deferred forever | Dependency never deposits completion (upstream task failed or plugin didn’t verify) | Check upstream task’s verify_goal result in episode JSON; fix the plugin’s completion condition |
| Pane activity doesn’t seem to speed anything up | --mux not set, so pane deposits never reach the scheduler’s field | Re-run with --mux, or confirm the multiplexer socket is up (castellan multiplexer ensure) |
| Two plugins fight over the same zone | Zones overlap in plugin.toml [zones] without intent | Give each plugin a distinct primary zone; share default zones deliberately |
| Wake pressure looks “stuck” below threshold | Perception radius too small to see the depositing zone | Widen the zone’s perception radius or deposit into a zone the waiting task actually watches |
| Coordination trace missing from episode | Run used --no-mux / in-process host without topology tracking | Re-run with --mux if you need pane_tree and per-zone deposit history |
See also
- Memory architecture — where coordination state gets persisted
- Organism model — how coordination parameters get evolved
- Plugin architecture — how plugins declare zones and blackboard slots
- Multiplexer overview — pane deposits and
--mux - Quickstart — first episode
Organism model
What
Castellan treats a harness as a living organism, not a static config file: a genome expresses coordination parameters, circulation vitals track field health tick-by-tick, and an immune gate rejects harmful topology mutations before they reach production.
Why
This layer turns “evolve topology” from a black box into something you can inspect, quarantine, and trust. Prefer organism terms over animal-pack or feudal metaphors — lexicon.
Read this page if you’re trying to understand what castellan vitals is actually showing you, why a mutation got rejected instead of accepted, or how genome lineage relates to the topology archive.
Status: Phases 1–4 are complete — circulation, DNA lineage, immune gate, and per-zone expression are all live in castellan-evolve/castellan-runtime.
Anatomy: where organism state lives
| Location | Holds |
|---|---|
castellan-core/organism.rs | DNA types, circulation vitals, per-zone expression |
castellan-substrate/pheromone | Per-zone decay (falls back to a global lambda if unset) |
castellan-runtime/circulation | Vitals computation + immune_fever tracking |
castellan-runtime/engine.rs | Wires vitals, genome_id, fever, and decay into the run loop |
castellan-evolve/genome.rs | Lineage, crossover, archive reconstruction |
castellan-evolve/immune.rs | Threat detection (ThreatPattern), auto_reject, quarantine recommendation |
castellan-evolve/mutator.rs | Crossover + cold-zone decay adjustment |
castellan-cli/organism_cmd.rs | castellan vitals, castellan genome list|lineage|map |
Bio-novelty extensions (H1–H9)
| Mechanism | Seam |
|---|---|
| Quorum phase locks | QuorumSensor + FalseQuorum immune |
| Response thresholds | AgentNode.response_threshold + surplus dispatch |
| Cement morphogenesis | cement deposits → MorphogenesisEngine |
| Trail / Physarum corridors | CorridorField |
| MAP-Elites archive | MapElitesArchive + castellan genome map |
| GRN expression | RegulatoryNetwork::express(vitals) |
| Live quarantine | ZoneQuarantine mid-run |
| Clonal immune memory | .castellan/immune_memory.json |
Circulation vitals also expose role_entropy, role_stickiness, inflammation, and mean_corridor_length when those mechanisms fire.
Organism loop
flowchart LR
RUN[castellan run] --> VITALS[circulation vitals]
VITALS --> EP[episode JSON]
EP --> EV[castellan evolve]
EV --> IMM[immune_report]
IMM -->|accept| ARCH[genome archive]
IMM -->|reject| QUAR[quarantine]
classDef runtime stroke:#0969da,stroke-width:2px
classDef evolve stroke:#d97706,stroke-width:2px
classDef gate stroke:#1a7f37,stroke-width:2px
class RUN,VITALS,EP runtime
class EV,ARCH evolve
class IMM,QUAR gate
- Every run expresses a
CastellanGenome— the coordination and decay parameters active for that episode. - The
PheromoneField’s circulation health lands in the episode asCirculationVitals. castellan evolvemutates or crosses over (requires a Pareto front with ≥ 2 non-quarantined entries) candidate genomes, then gates every candidate throughimmune_report().- Quarantined genomes are excluded from
archive.nearest()— they can never be selected as a seed for a futurecastellan run --seed-archive. - Per-zone decay gets tuned from the proposer’s cold-zone heatmap, so zones that go quiet decay differently from zones under constant pressure.
The immune gate, concretely
ImmuneReport carries two independent signals per candidate:
| Field | Meaning | Effect |
|---|---|---|
auto_reject | Set when threats include RunawayParallel or other severe patterns | Candidate never enters the archive this generation |
quarantine_recommended | Set whenever any threat pattern is detected | Candidate is marked quarantined: true; excluded from future nearest() seeding even if accepted |
A genome can be accepted into the archive but still quarantined — quarantine is about seeding safety, not archive membership. Check castellan genome list output for the quarantined flag per entry.
How operators use it
1. Run a goal — a genome is expressed automatically, no flags needed:
castellan run --goal "reach target" --plugin gridworld
2. Inspect circulation health for the most recent (or a specific) episode:
castellan vitals
castellan vitals --episode .castellan/episodes/<goal-id>.json
3. Evolve genomes from the episode corpus — this is where the immune gate runs:
castellan evolve --episodes 10 --workspace .castellan/episodes
4. List genomes and trace lineage:
castellan genome list
castellan genome lineage <genome_id>
5. Seed a new run from a surviving (non-quarantined) genome:
castellan run --goal "reach target" --plugin gridworld --seed-archive --write-back
Phase completion
| Phase | Deliverable | Status |
|---|---|---|
| 1 Circulation | circulation_vitals, castellan vitals, fitness dimension | ✅ |
| 2 DNA | genome_id lineage, archive seed reconstruction, castellan genome | ✅ |
| 3 Immune | Drift + fitness + immune gate, quarantine, fever deposit | ✅ |
| 4 Expression | Per-zone decay, crossover, flux-grid test, fever unit test | ✅ |
flux-grid (optional, experimental)
- Feature flag:
castellan-substrate/flux-grid— a vendored 64×64Stigmergygrid. - Not enabled in default builds. The zone-keyed
PheromoneFieldremains the default substrate. - Enable for experiments:
cargo build -p castellan-substrate --features flux-grid
Recipes
When a run’s fitness looks fine but the field “feels” unhealthy, check castellan vitals before trusting the number — circulation vitals surface stalls and dead zones that a single scalar fitness score can hide.
When castellan evolve keeps rejecting your best-looking candidates, dump immune_report reasoning via --plan (read-only, zero writes) before assuming the mutator is broken — the drift or immune gate may be catching a real structural problem.
When you want to know if a genome is safe to seed from, always check quarantined on castellan genome list, not just whether it’s present in the archive — presence and safety are separate signals.
When investigating a lineage regression, castellan genome lineage <id> walks parent_genome_id; note that crossover sets a full parent_ids list internally, but the CLI currently surfaces only the primary parent (see Known deferred below).
Failure paths / troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
castellan vitals reports no data | No episodes yet, or wrong --workspace | Run castellan run first; confirm .castellan/episodes/ has files |
Genome accepted but castellan run --seed-archive never picks it | Genome is quarantined: true | Check castellan genome list; quarantined entries are excluded from nearest() |
| Crossover never happens | Pareto front has fewer than 2 non-quarantined entries | Run more episodes/generations to build a larger accepted pool |
| Lineage shows only one parent for a crossover genome | CLI limitation — parent_ids is tracked internally but only parent_genome_id is surfaced | Known deferred; inspect the archive JSON directly for full parent_ids if needed |
flux-grid feature won’t build | Not enabled in default feature set | Build with cargo build -p castellan-substrate --features flux-grid explicitly |
Known deferred
flux-gridis not wired into theswarm-demodefault path.- Full
parent_idslineage is not yet exposed by the CLI (only primaryparent_genome_id). RAH_MAX_DEPTHcycle avoidance betweencastellan-evolveandcastellan-rahlives incastellan-corebut isn’t documented end-to-end yet.
See also
- Coordination model — the substrate whose parameters genomes express
- Topology evolution — the evolve loop that produces and gates genomes
- Evolve round-trip — seeding a run from the archive and writing fitness back
- Quickstart — first episode
- Episode flywheel on Introduction
Topology evolution
What
Evolution that changes coordination geometry — wake thresholds, topology nodes, per-zone decay — not prompt strings. castellan evolve reads real episode logs, proposes structural mutations, gates them through drift + immune checks, and keeps only genomes that measurably improve plugin-grounded fitness.
Why
Outcome: improve harness topology from episode logs under governance gates — fitness from geometry you can replay, not from rewriting prompts. Round-trip seeding: Evolve round-trip. Lexicon: Conventions.
If you’re trying to prove the evolve loop works, debug why a generation got rejected, or wire evolve into CI, this page walks the pipeline end to end.
What this proves
- Episode logs from real
castellan run/swarm-demoruns ground fitness viaplugin_grounded_fitness()— not a synthetic benchmark. castellan evolve --episodes 10runs accept/reject over generations, gated by drift, convergence, and immune gates.- Drift gate: structurally incompatible candidates (e.g. edges referencing missing nodes) are rejected on the production path;
drift_rejectedanddrift_violationsappear in CLI JSON output when the gate fires. - Convergence gate (potential-game): candidates with zero decay, unreachable wake thresholds, or disconnected stigmergy topology are rejected outright, and any candidate whose potential Φ drops below the incumbent’s is rejected even when fitness improves (
convergence:potential_decline);convergence_rejectedandconvergence_violationsappear in CLI JSON. - Gen-0 vs gen-N fitness delta and full genome lineage are observable directly in CLI output — nothing is hidden behind an opaque score.
- MAP-Elites (H5): behavior descriptors illuminate a grid of viable geometries (
castellan genome map); multi-objectiveFitnessVectorkeeps success + circulation per cell. - Clonal immune memory (H8): failure antigens clone detectors into
.castellan/immune_memory.jsonso similar failures quarantine faster than static enums alone.
Anatomy: the evolve pipeline
flowchart LR
EP[episode corpus] --> PROP[proposer]
PROP --> MUT[mutation candidate]
MUT --> DRIFT{drift gate}
DRIFT -->|fail| REJ1[rejected: drift_violations]
DRIFT -->|pass| CONV{convergence gate}
CONV -->|fail| REJ1B[rejected: convergence_violations]
CONV -->|pass| IMM{immune gate}
IMM -->|auto_reject| REJ2[rejected: quarantine candidate]
IMM -->|pass| FIT[plugin_grounded_fitness]
FIT --> ARCH[topology archive]
classDef evolve stroke:#d97706,stroke-width:2px
classDef gate stroke:#1a7f37,stroke-width:2px
class EP,PROP,MUT,FIT,ARCH evolve
class DRIFT,IMM gate
| Stage | Question it answers | Where it lives |
|---|---|---|
| Proposer | What mutation should we try? | castellan-evolve/proposer.rs — reads pheromone heatmaps + wake counts; field-shaped birth/death targets cold zones (role: zone:{name}); live MCP instrument deposits (instrument_summary / .castellan/live/) bias birth toward instrumented zones (instrument_informed on evolve JSON) |
| Drift gate | Is the candidate structurally valid? | evolution_drift_violations before fitness |
| Convergence gate | Does the candidate satisfy potential-game assumptions (decay > 0, viable wake, stigmergy connectivity) and keep the potential function Φ non-decreasing (coverage + decay band − deferred pressure − entropy − wake mismatch)? | evaluate_convergence / coordination_potential in castellan-evolve/convergence.rs |
| Immune gate | Does the candidate exhibit a known threat pattern? | castellan-evolve/immune.rs → immune_report() |
| Fitness | Did it actually work better? | plugin_grounded_fitness() against real episodes |
| Archive | Should we keep it, and is it safe to seed from later? | castellan-evolve/archive.rs → TopologyArchive |
How operators use it
1. Build the CLI and run the scripted proof end to end:
cargo build --release -p castellan-cli
bash docs/demo/evolve-proof.sh
2. Or step through it manually — generate real episodes, then evolve from them:
castellan swarm-demo
castellan run --goal "reach target" --plugin gridworld # repeat 3x for a small corpus
castellan evolve --episodes 10 --workspace .castellan/episodes
castellan genome list
castellan genome lineage <genome_id>
3. Do a read-only analysis pass before committing to a mutation:
castellan evolve --plan
--plan emits an evolution plan JSON (incumbent, proposed coordination, pressure summary, drift preview, fitness estimate) with zero writes — safe to run against a production archive.
4. Two-phase gated apply when you want a human or CI check between propose and apply:
castellan evolve --dry-run # writes .castellan/proposals/<uuid>.json, no archive writes
castellan evolve --apply-proposal <uuid> # re-checks drift gate, then applies
5. Live pane topology apply — after field-shaped mutations, apply the seed→target delta to an in-process multiplexer pane tree (typed birth/prune, not chat routing):
castellan evolve --episodes 3 --workspace .castellan/episodes --apply-live
JSON includes live_apply (born, pruned, pane_count). When gates reject the incumbent update, --apply-live still trials the last field-shaped candidate onto live panes and writes .castellan/live/<goal>.topology_apply.jsonl (MCP attach remains instrument-only).
Mid-run drain: while a live-instrumented castellan run is active, the engine drains that JSONL on scheduler ticks (latest-wins), updates topology + wake threshold, emits topology_apply_drained / CastellanEvent::TopologyApplyDrained, and on --mux reconciles panes via RAH apply — without a separate --apply-live pass.
6. Wire the same two-phase flow through MCP (castellan_propose_mutation / castellan_resolve_mutation) when driving evolve from an editor agent instead of the CLI.
Sample output shape
{
"generations": 10,
"accepted": 4,
"rejected": 6,
"drift_rejected": 1,
"drift_violations": [
{ "check_id": "topology_edge_refs", "message": "edge agent-1→agent-2 references missing node(s)" }
],
"gen0_fitness": 0.69,
"final_fitness": 0.74,
"fitness_delta": 0.05,
"field_mutations": ["birth_cold_zone", "tune_wake"],
"instrument_informed": true,
"seed_source": "episodes",
"pareto_front": []
}
| Field | Meaning |
|---|---|
accepted / rejected | Count of genomes that survived vs. were dropped this evolve call |
drift_rejected | Subset of rejected that failed the structural drift gate specifically |
drift_violations | One entry per structural failure, with a stable check_id |
gen0_fitness / final_fitness / fitness_delta | Plugin-grounded fitness trend across generations |
field_mutations | Field-shaped actions applied (birth_cold_zone, tune_wake, …) |
instrument_informed | True when live MCP instrument deposits biased those field mutations |
seed_source | Where the initial population came from (episodes here) |
pareto_front | Non-dominated candidates eligible for crossover next round |
Recipes
When you want a fast, LLM-free CI smoke test of the evolve loop, run the fixture-backed proof rather than a live corpus:
cargo run -q -p evolve_proof
This uses tests/fixtures/evolve-episodes/ (4 gridworld episodes) — no network, no LLM, deterministic.
When a generation rejects everything, check drift_violations first — a structural issue (missing node reference, invalid zone) will reject before fitness is even computed, so a 100% rejection rate often means one bad mutation shape, not a fitness problem.
When you want continuous improvement without babysitting every generation, use --seed-archive + --write-back on production runs (see Evolve round-trip) so successful runs feed fitness back automatically.
When reviewing a mutation before it goes live, prefer --plan → --dry-run → --apply-proposal over a bare castellan evolve, so you get an inspection point between proposal and archive write.
Honest limits
Failure paths / troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
castellan evolve exits with “no episodes found” | Wrong --workspace, or no runs have completed yet | Run castellan run a few times first; pass --workspace .castellan/episodes explicitly |
| 100% rejection rate | Drift gate catching a structural issue in every candidate | Inspect drift_violations[].check_id; fix the proposer input or plugin manifest referenced |
| Crossover never triggers | Pareto front has fewer than 2 non-quarantined survivors | Run more generations/episodes to build a larger accepted pool |
| Fitness delta looks suspiciously high | Small/unrepresentative episode corpus | Grow the corpus with real castellan run episodes before trusting the delta |
--apply-proposal fails after a successful --dry-run | Proposal re-checked against drift gate and failed on apply (archive changed in between) | Re-run --dry-run to regenerate a fresh proposal against current archive state |
Fast-slow co-evolution
Castellan splits fast per-episode RLM remediation from slow topology evolution — two timescales on the same episode corpus, not a monolithic agent chat loop.
| Loop | Speed | Mechanism | Config |
|---|---|---|---|
| Fast | Every episode | Failed episodes → fast_remediation observations + pheromone hints | [coevolution] fast_remediation = true |
| Slow | Every N episodes | In-tree castellan evolve on episode corpus | [coevolution] slow_evolve_every = 4 |
[coevolution]
fast_remediation = true
slow_evolve_every = 4
slow_evolve_generations = 2
Demo:
bash docs/demo/fast-slow-coevolution.sh
State is tracked in .castellan/flywheel_state.json; slow evolve emits a slow_evolve event in episode JSON when it runs.
Related
- Evolve round-trip — seeding runs from the archive and writing fitness back
- Organism model — genomes, circulation vitals, and the immune gate in depth
- Stigmergy ablation
castellan evolvereference
Evolve → run round-trip
What
Loop: episodes → evolve → archive → seed run → write-back fitness.
Why
It’s not enough for castellan evolve to find a better genome once — the round-trip closes so a production run can seed from that genome, and its outcome feeds the archive for the next evolve pass. See Topology evolution and Organism model.
Overview
Castellan closes the loop between topology evolution and production runs in three steps:
castellan evolve— mutates harness topology from the episode corpus; writes.castellan/topology_archive.json.castellan run --seed-archive— seeds topology/coordination from the nearest archive entry (matched by plugin + goalenvironment_fingerprint); logs agenome_idand agenome_seedevent in the episode.- Write-back — after a successful run, merges the new episode’s fitness into the matching archive entry (matched by
genome_id), then re-sorts the archive by success score.
Write-back is enabled automatically whenever --seed-archive is used, or explicitly with --write-back. Pass --no-write-back to disable the automatic path (useful when you want to seed from a known-good genome without letting a noisy or exploratory run pollute its fitness history).
Anatomy: what’s in an archive entry
Each TopologyEntry in .castellan/topology_archive.json carries:
| Field | Meaning |
|---|---|
id | Archive entry UUID |
coordination | HarnessCoordination — the topology + wake/decay parameters this entry expresses |
fitness | FitnessVector — plugin-grounded scores, updated by write-back |
generation | Which evolve generation produced this entry |
environment_fingerprint | Plugin + goal signature used by nearest() to match seed candidates |
genome_id / parent_genome_id | Lineage — see Organism model |
quarantined | Excludes this entry from nearest() regardless of fitness — see Organism model |
decay_lambda, zone_decay, zone_weights | Per-zone tuning this genome expresses |
flowchart LR
EP[episode corpus] --> EV[castellan evolve]
EV --> ARCH[topology_archive.json]
ARCH --> SEED[castellan run --seed-archive]
SEED --> RUN[episode: genome_seed event]
RUN -->|Satisfied + fitness| WB[write_back_fitness]
WB --> ARCH
classDef evolve stroke:#d97706,stroke-width:2px
classDef runtime stroke:#0969da,stroke-width:2px
class EP,EV,ARCH,WB evolve
class SEED,RUN runtime
How operators use it
1. Quick reproduce from a fixture corpus:
EVOLVE_FIX="tests/fixtures/evolve-episodes"
mkdir -p .castellan/episodes
cp "$EVOLVE_FIX"/*.json .castellan/episodes/
castellan evolve --episodes 10 --workspace .castellan/episodes
castellan run --goal "reach target" --plugin gridworld --seed-archive --json
Expect in the resulting episode JSON:
genome_id— seeded from the nearest archive entryfitness— plugin-grounded success score for this runevents[]containing agenome_seedevent, and — when write-back applies — anarchive_write_backevent
2. Seed from a specific genome instead of “nearest”:
castellan run --goal "reach target" --plugin gridworld --genome <genome_id>
--genome and --seed-archive are mutually exclusive — pick one.
3. Seed without letting the run mutate the archive (e.g. reproducing a past result exactly):
castellan run --goal "reach target" --plugin gridworld --genome <genome_id> --no-write-back
4. Point at a non-default archive path (e.g. testing a candidate archive before promoting it):
castellan run --goal "reach target" --plugin gridworld --seed-archive --archive .castellan/topology_archive.candidate.json
Write-back semantics
- Trigger:
GoalStatus::Satisfiedand bothgenome_idandfitnesspresent on the episode — a failed or inconclusive run never writes back. - Merge:
TopologyArchive::write_back_fitnessupdates the matching entry byid, then re-sorts the archive by success score so futurenearest()calls prefer the freshest evidence. - Archive path: controlled by
--archive(default.castellan/topology_archive.json).
JSON events
With --json, archive write-back emits a typed archive_write_back CastellanEvent line, immediately before episode_end. Automation watching the NDJSON stream can key off this event to know the archive changed without re-reading the file.
Recipes
When proving the round-trip works end to end (e.g. for a demo or CI check), use the fixture corpus reproduction above — it’s deterministic and doesn’t depend on a live LLM or long-running episodes.
When you want continuous improvement in a daily-driver loop, always pass --seed-archive on production runs (write-back is then automatic) so every successful run compounds into the next evolve generation.
When comparing “does seeding actually help,” run the same goal once with --seed-archive and once without, then diff fitness in the two episode JSONs — the delta is the seeding effect for that goal/plugin pair.
When you need a stable baseline for regression testing, seed with an explicit --genome <id> and --no-write-back so the archive never drifts between test runs.
Failure paths / troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
--seed-archive seeds nothing (no genome_seed event) | No archive entry matches the plugin+goal environment_fingerprint, or all matching entries are quarantined | Run castellan evolve first to populate the archive; check castellan genome list for non-quarantined entries |
error: specify only one of --genome or --seed-archive | Both flags passed together | Pick one — explicit genome or nearest-match auto-seed |
No archive_write_back event even though the run succeeded | Episode’s fitness or genome_id missing, or --no-write-back was set | Confirm the plugin’s verify_goal sets GoalStatus::Satisfied; drop --no-write-back if present |
| Write-back seems to “lose” a good fitness score | A later, worse run wrote back to the same genome_id and got re-sorted lower | Use --no-write-back when reproducing/benchmarking a known-good genome so exploratory runs can’t overwrite it |
--archive <path> run can’t find the file | Path doesn’t exist yet — archive is created by castellan evolve, not by run | Run castellan evolve --workspace .castellan/episodes once to create the archive file first |
Related
- Topology evolution — how the archive gets populated and gated
- Organism model — genome lineage and the immune gate
- CLI reference
castellan runreference
Memory architecture
What
Castellan memory is the coordination substrate plus durable episode artifacts — it is not a vector store, not a conversation archive, and not RAG. There is no embedding index in the default path; “recall” means querying structured episode/checkpoint/observation records, and “remember” means persisting a typed observation, not chunking text for similarity search.
Why
Hot field state (blackboard + pheromones) must stay fast and ephemeral; warm episodes must remain replayable for evolve. Lexicon: Conventions.
If you’re deciding whether to enable SQLite, wiring castellan_recall/castellan_remember from an MCP client, or figuring out where a run’s state actually lives on disk, this page is the map.
Anatomy: hot / warm / cold layers
| Layer | What lives here | Where | Lifetime |
|---|---|---|---|
| Hot | Blackboard, PheromoneField, scheduler queue | In-process memory | One run |
| Warm | Episodes, traces, checkpoints, topology archive | .castellan/ — JSON by default, or SQLite | Durable, local disk |
| Cold | Turso Sync (libSQL replica) | Multi-machine share | Durable, remote — off by default, opt-in |
flowchart TB
subgraph hot["Hot"]
BB[Blackboard]
FIELD[PheromoneField]
SCHED[Scheduler]
end
subgraph warm["Warm"]
EP[episodes]
TR[traces]
TA[topology archive]
end
RUN[castellan run] --> SCHED
SCHED --> FIELD
RUN --> BB
RUN --> EP
RUN --> TR
EVOLVE[castellan evolve] --> EP
EVOLVE --> TA
MCP[castellan mcp] --> warm
REPLAY[castellan replay] --> EP
classDef runtime stroke:#0969da,stroke-width:2px
classDef substrate stroke:#5b6b8c,stroke-width:2px
classDef evolve stroke:#d97706,stroke-width:2px
class RUN,REPLAY runtime
class BB,FIELD,SCHED,MCP substrate
class EVOLVE,EP,TR,TA evolve
On-disk layout
| Artifact | Path | Written by |
|---|---|---|
| Episodes | .castellan/episodes/<goal-id>.json | castellan run (episode end) |
| Traces | .castellan/traces/<goal-id>.jsonl | castellan run (per-tick) |
| Checkpoints | .castellan/checkpoints/ | castellan run (for --resume) |
| Topology archive | .castellan/topology_archive.json | castellan evolve, and castellan run --write-back |
| Proposals | .castellan/proposals/ | castellan evolve --dry-run |
| SQLite DB (optional) | .castellan/castellan.db (configurable via [memory] path) | castellan run/evolve when backend = "sqlite" |
How operators use it
1. Default path — nothing to configure. Every run writes episode JSON automatically:
castellan run --goal "reach target" --plugin gridworld
castellan replay --episode .castellan/episodes/<id>.json
castellan memory status
2. Read live substrate state from an editor/MCP client while a run is active:
3. Switch to the SQLite backend when you want queryable aggregates instead of scanning JSON files. Add to castellan.toml:
[memory]
backend = "sqlite"
path = ".castellan/castellan.db"
mirror_json = true
auto_import = true
mirror_json (default true) keeps writing .castellan/episodes/*.json alongside SQL so tooling that reads plain JSON keeps working. auto_import (default true) imports any existing JSON corpus the first time SQLite opens.
4. Query durable memory from MCP tools once SQLite is enabled:
5. Confirm which backend is active and where the DB lives:
castellan memory status
6. Mid-episode durable checkpoints: persist scheduler state during a run so castellan run --resume survives process loss — not just episode-end snapshots.
[memory]
checkpoint_interval_ticks = 10 # 0 = episode-end only (default, backward-compatible)
Checkpoints land in .castellan/checkpoints/ and SQLite field_history when backend = "sqlite". The engine fires on_checkpoint every N ticks; CLI wires this to MemoryStore::save_checkpoint_json. Integration tests: durable_checkpoint.rs, durable_resume.rs.
Recipes
When you’re prototyping a plugin, stay on the JSON default — episodes are human-readable, diffable, and git-friendly for fixture corpora (see tests/fixtures/evolve-episodes/).
When your episode corpus grows past a few hundred runs and castellan evolve gets slow, switch to backend = "sqlite" so evolve reads SQL aggregates instead of re-parsing every JSON file.
When you need memory to survive across machines (e.g. a shared team archive), configure [memory.sync] with a Turso/libSQL URL and set sync_on_episode_end = true — tokens are referenced by env var name (turso_token_env), never stored inline in castellan.toml. On each castellan run / MCP session open, Castellan pulls newer remote episodes into the local SQLite file; after each episode ends it pushes that episode’s rows when sync_on_episode_end = true.
Check sync health:
castellan doctor --json | jq '.stages[] | select(.name=="memory_sync")'
The memory_sync stage reports reachability, last pull/push timestamps, and the last error (if any). State is stored in .castellan/sync_state.json (no tokens).
When you want a clean corpus for a benchmark run, cp a known-good fixture set into .castellan/episodes/ before castellan evolve rather than hand-editing the live archive.
What never goes in the database
| Data | Why |
|---|---|
| Live pheromone ticks | In-process decay semantics only make sense mid-run; snapshots go in, not the tick stream |
| Conversation transcripts | Anti-coordination design — see Coordination model |
| Vector embeddings | Out of scope for v1 — no similarity search in the default path |
Failure paths / troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
castellan evolve says “no episodes found” | Wrong --workspace, or episodes never written (run failed before episode_end) | Confirm .castellan/episodes/ has files; pass --workspace .castellan/episodes explicitly |
| SQLite backend configured but episodes still look empty | auto_import ran before any JSON existed, or path points to a fresh DB | Re-run castellan doctor --json to confirm the resolved DB path; check mirror_json output |
castellan_recall returns nothing | Backend is still json (default) — castellan_recall is a SQLite-backed tool | Set [memory] backend = "sqlite" in castellan.toml |
| Turso sync silently not happening | sync.turso_url unset, sync_on_episode_end false, or [memory] backend not sqlite | Set turso_url, sync_on_episode_end = true, backend = "sqlite"; export token env from turso_token_env; castellan doctor memory_sync stage shows last error |
| Two commands disagree on episode count | One reads JSON, one reads SQLite, and mirror_json was toggled mid-project | Keep mirror_json = true, or fully migrate and stop reading the JSON tree |
Engineering detail
Full SQL schema, the MemoryStore trait, and sync policy internals: MEMORY_ARCHITECTURE.md (maintainer spec, not a book page).
See also
- Coordination model — the hot layer this page’s “warm” tier is a snapshot of
- Topology evolution — the primary reader of the warm layer
castellan.tomlreference — full[memory]key list- MCP tools —
castellan_recall,castellan_remember,castellan_query_field_history
RLM loop
In-tree verify/act loop: castellan run --rlm.
Sandbox host (RlmSandboxHost)
Hardened process-isolated code eval ships behind the sandbox feature on castellan-rlm (enabled by default on castellan-cli via rlm-sandbox).
| Backend | Type | When |
|---|---|---|
StubSandboxHost | Default without sandbox feature | Returns stub message |
ProcessSandboxHost | python3 -c subprocess + timeout | sandbox_backend = "process" |
HardenedProcessSandboxHost | Temp workspace, no network, platform hardening | Default (sandbox_backend = "hardened") |
BubblewrapSandboxHost | bwrap on PATH with ro-bind + unshare-net | sandbox_backend = "bubblewrap" (falls back to hardened) |
WasmSandboxHost | wasmtime echo guest (minimal eval scope) | sandbox_backend = "wasm" with --features wasm-sandbox (experimental; not default) |
Configure via CASTELLAN_SANDBOX_BACKEND=process|hardened|bubblewrap or [rlm] sandbox_backend in castellan.toml. WASM requires the optional wasm-sandbox feature — not enabled by default.
Hardened process remains the default hot path. WASM ships a minimal echo guest for REPL eval wiring only — not Python parity.
REPL eval path
When an observation includes an rlm_eval block, the RLM loop executes code through the sandbox host before verify:
{
"rlm_eval": { "code": "print(2 + 2)", "timeout_ms": 5000 },
"rlm_eval_result": { "stdout": "4\n", "stderr": "", "exit_code": 0 }
}
Wiring:
castellan-rlm:repl_eval_observation,run_until_satisfied(..., sandbox_host),select_sandbox_host()castellan run --rlm: mux RLM path usesselect_sandbox_host()(hardened default)
Build / test
cargo test -p castellan-rlm --features sandbox
CASTELLAN_SANDBOX_BACKEND=bubblewrap cargo test -p castellan-rlm --features sandbox
CASTELLAN_SANDBOX_BACKEND=wasm cargo test -p castellan-rlm --features sandbox,wasm-sandbox
cargo test -p castellan-cli rlm
Multiplexer overview
Castellan ships an in-tree PTY multiplexer with a Multiplexer-shaped NDJSON socket API. One binary (castellan) serves daily-driver mux panes — workspaces, tabs, splits, agent detection — without requiring a separate external mux client binary. It is both a terminal manager you can drive by hand (castellan herd attach) and a coordination substrate the scheduler can drive automatically (castellan run --mux).
If you’re setting up a daily-driver pane layout, wiring an editor agent to report its own state, or trying to understand what --mux actually spawns, this page is the map.
Anatomy
castellan herd server → castellan-multiplexer (Unix socket)
│
├── workspace / tab / pane tree (session.snapshot, workspace.*, tab.*, pane.*)
├── pane.spawn / pane.attach (PTY proxy, streaming)
├── events.subscribe / events.emit (NDJSON EventBus — live Castellan events when socket healthy)
└── pane.report_agent (hook protocol — authoritative agent state)
| Concept | What it is |
|---|---|
| Server | Long-lived process owning the Unix socket and the pane tree; started by castellan herd server or castellan multiplexer server |
| Workspace | Top-level grouping of tabs (roughly: one project or one logical session) |
| Tab | A BSP-splittable container of panes |
| Pane | One PTY — either a plain shell, or a pane running an agent (Claude/Codex/Cursor/etc.) |
| Session | A named server instance with its own socket + persist dir — lets you run multiple independent multiplexer servers |
Pane agent status feeds the coordination substrate directly: agent_status observations can carry pheromone_deposits, which boost scheduler wake pressure on the pane zone. See Coordination model for the mechanism.
flowchart LR
SRV[castellan herd server] --> SOCK[Unix socket]
SOCK --> PANE[panes]
PANE --> AGENT[agent_status]
AGENT --> DEPOSIT[pheromone_deposits]
DEPOSIT --> FIELD[pane zone]
FIELD --> SCHED[scheduler wake boost]
classDef runtime stroke:#0969da,stroke-width:2px
classDef substrate stroke:#5b6b8c,stroke-width:2px
class SRV,SOCK,PANE,AGENT runtime
class DEPOSIT,FIELD,SCHED substrate
How operators use it
1. Start the server (idempotent — spawns a daemon only if none is running):
castellan multiplexer ensure
# or, Multiplexer-shaped:
castellan herd server
Default socket: ~/.config/castellan/castellan.sock (override with CASTELLAN_SOCKET or --socket).
2. Check status — the rich agent dashboard, not just “is it up”:
castellan herd status
3. Attach interactively to a pane:
castellan herd attach # focused pane in first workspace
castellan herd attach --pane w1:p1 # specific pane
Detach with Ctrl+Q. Use --takeover if another client already owns input.
4. Let the scheduler drive panes automatically instead of attaching by hand:
castellan run --goal "reach target" --plugin gridworld --mux
castellan swarm-demo # scripted multi-pane demo, no goal needed
5. Wait for a pane’s agent to go idle before sending it more work (scripting/CI):
castellan herd wait --pane w1:p1 --status idle --timeout-ms 60000
6. Run over SSH when the multiplexer lives on a remote host:
castellan herd remote user@host
# or the lower-level goal runner:
castellan remote --ssh user@host --goal "reach target" --plugin gridworld
Attach modes
| Mode | Command | Use |
|---|---|---|
| Poll | castellan herd status | Headless CI, scripts — snapshot, no session held open |
| Direct attach | castellan herd attach | Daily-driver interactive TTY |
| Terminal attach (raw) | castellan herd terminal attach <term_id> | Attach by terminal id instead of pane label |
| Observe (read-only) | castellan herd terminal observe --pane <id> | Stream frames without taking input ownership |
| Named session | castellan herd session attach <name> | Multiple independent multiplexer servers side by side |
Recipes
When you want one shared pane layout across a team or CI matrix, use a named session (--session <name>) so each gets its own socket and persist directory instead of colliding on the default socket.
When an editor agent should report its own state instead of relying on screen-scraping heuristics, wire pane.report_agent from a hook script — see Agent hooks for the exact NDJSON payload and shell wrapper.
When you need to restart the multiplexer without losing your pane layout, use server.live_handoff (via the socket API) rather than killing the process — layout and visible text are preserved, but note PTY processes themselves are not preserved across handoff.
When debugging “why didn’t my mux run pick up pane activity,” confirm the run actually used --mux (in-process host with topology tracking) — without it, pane deposits never reach the scheduler’s field.
When the dashboard last_events stays empty during castellan run, ensure the mux socket is healthy (castellan multiplexer ensure). Healthy sockets receive Castellan events via events.emit; panes still use the in-process host. See Statusline.
Failure paths / troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
castellan herd status hangs or errors | No server running, or wrong socket path | Run castellan multiplexer ensure first; check CASTELLAN_SOCKET env var |
castellan herd attach immediately detaches | Pane doesn’t exist or was closed | List panes first with castellan herd tabs; confirm the --pane id |
| Two terminals fight for input on the same pane | Both attached without --takeover | Use --takeover on the attach that should own input; the other becomes read-only |
--mux run doesn’t show pane_tree in episode JSON | Run used --no-mux, or the multiplexer socket wasn’t reachable at run start | Confirm castellan herd status succeeds before the run; drop --no-mux |
| Server restart loses running commands | server.live_handoff preserves layout/text only, not PTY processes | Expected behavior — for long-running commands, prefer a session you don’t restart, or checkpoint work externally |
| Remote SSH attach fails | Remote multiplexer socket not forwarded, or SSH alias misconfigured | Verify ~/.ssh/config alias resolves; try castellan remote --ssh user@host directly to isolate SSH vs. multiplexer issues |
Related
- Socket API — full NDJSON method catalog
- Agent hooks —
pane.report_agentand lifecycle reporting - Coordination model — how pane deposits feed the scheduler
- Daily driver loop
castellan herdreference
Dashboard
castellan dashboard is the ratatui operator UI over the in-tree multiplexer — agents, pressure, pane topology, and the evolution archive in one view.
What / why
Live stigmergy control tower: field heat from deposits, plan_verify / bio / cost chips, dual-bus honesty — without embedding a PTY grid (Enter still hands off to herd attach). Headless observers use the same ObservabilitySnapshot via castellan statusline refresh or ~/.config/castellan/statusline.json.
Launch
castellan multiplexer ensure
castellan dashboard
Defaults:
| Setting | Default | Override |
|---|---|---|
| Socket | ~/.config/castellan/castellan.sock | --socket <PATH> |
| Episodes | .castellan/episodes | --episodes <PATH> |
| Archive | .castellan/topology_archive.json | --archive <PATH> |
Anatomy
The dashboard renders four coordinated views over one DashboardState, refreshed on demand:
| Pane | Shows | Source |
|---|---|---|
| Agent list | Pane id, agent name, status (idle/working/blocked/done/unknown) | agent.list over the multiplexer socket |
| Pressure strip | Episode vitals and live deposit heat (live_pressure) | Episodes + EventBus deposit / wake intensity |
| Genome ticker | Active genome id, generation, lineage depth | .castellan/topology_archive.json |
| Footer chips | socket/events/panes, plan_verify, bio, Φ, cost | Dual-bus honesty + castellan-events |
| Live events | Recent Castellan events (last_events, cap 16) | Socket EventBus — castellan run / ACP via events.emit when healthy |
| Attach | External PTY for selected pane | Enter → herd attach (no in-TUI multi-pane grid) |
Keybindings
| Key | Action |
|---|---|
j / k or ↓ / ↑ | Select agent up/down |
Enter | Attach to selected pane (Ctrl+Q to detach) |
v | Toggle vitals strip |
r | Refresh agents + layout snapshot |
q / Esc | Quit |
Recipes
Daily-driver stack:
castellan multiplexer ensure
castellan dashboard
Point at a named session (multi-project):
castellan herd session list
castellan dashboard --socket ~/.config/castellan/<session>.sock
Headless history (telemetry DB, no TUI):
castellan dashboard --history --limit 20
# same as:
castellan telemetry --limit 20
Headless live snapshot (no ratatui):
castellan statusline refresh --json | jq '{schema_version, bus, live_pressure, plan_verify, bio}'
Read a previously written file:
jq '{schema_version, connected, agents, vitals}' ~/.config/castellan/statusline.json
Headless observability
TUI and statusline refresh write the same ObservabilitySnapshot — schema in Statusline. MCP castellan_multiplexer_status adds freshness (stale, age_secs).
Honest gap: Full multiplexer chrome in the ratatui dashboard is still deferred; the current surface is agents, pressure, and pane tree.
Failure paths
| Symptom | Cause | Fix |
|---|---|---|
| “connection refused” on launch | No multiplexer server running | castellan multiplexer ensure before castellan dashboard |
| Agent list empty | No panes spawned yet | Start an agent pane first (castellan herd attach or spawn via socket API) |
| Vitals strip blank | No episode has run yet | castellan run --goal "..." --plugin gridworld at least once |
last_events empty while a run is active | Mux socket unhealthy, or run used --no-mux / private-only path | castellan multiplexer ensure; confirm doctor mux_default mentions socket EventBus; drop --no-mux |
| Genome ticker blank | Archive missing or empty | castellan evolve --episodes 10 to populate .castellan/topology_archive.json |
| Attach hangs on Ctrl+Q | Terminal emulator swallowing the escape sequence | Detach with q from the agent list view instead |
CI smoke
Dashboard client code is tested against a mock Unix socket:
cargo test -p castellan-dashboard --test integration
Included in ./scripts/verify.sh.
Related
Governance overview
Castellan ships two independent governance surfaces, and neither one routes decisions through an LLM: governance is deterministic policy plus operator-configured shell hooks.
Canon (cite; don’t redesign): dual-stack diagram in repo docs/GOVERNANCE.md — Tools (PermissionPolicy → plan_verify → hooks → execute) and Evolve (drift → Φ → fitness → immune).
- Manifest drift-check —
castellan drift-checkenforces required CI checks, style files, and deprecation notices against a single source of truth (VERIFICATION_MANIFEST). See Governance policies. - Runtime governance — permission presets, shell command policy, spend/risk caps, and lifecycle hooks configured in layered
castellan.toml. See Agent hooks.
If you’re deciding what an agent is allowed to run, why a tool call got denied, or whether your repo passes CI’s guardrail gate, this page is the starting point — the two linked pages below go deep on each surface.
Anatomy: the two surfaces
| Surface | Question it answers | Enforced by | Config |
|---|---|---|---|
| Manifest drift-check | Does the repo still satisfy required CI/style/doc guardrails? | castellan drift-check, scripts/drift-guard.sh | crates/castellan-governance/src/manifest.rs (code, not TOML) |
| Permission policy | Is this MCP tool call / shell command allowed right now? | ToolGovernancePipeline, ShellSandbox | [permissions], [shell] in castellan.toml |
| Spend / risk caps | Has this session exceeded budget or risk thresholds? | ToolGovernancePipeline | [governance] in castellan.toml |
| Lifecycle hooks | What deterministic shell command fires on session/tool events? | castellan run, MCP tool calls | [[hooks.hooks]] in castellan.toml |
flowchart TB
CFG[castellan.toml layers] --> PERM[permission preset]
CFG --> SHELL[shell allow/deny lists]
CFG --> CAP[spend + risk caps]
CFG --> HOOKS[lifecycle hooks]
PERM --> PIPE[ToolGovernancePipeline]
SHELL --> PIPE
CAP --> PIPE
HOOKS --> PIPE
PIPE --> RUN[castellan run / mcp]
RUN --> AUDIT[audit JSONL]
MANIFEST[VERIFICATION_MANIFEST] --> DRIFT[castellan drift-check]
DRIFT --> CI[CI gate]
classDef gate stroke:#1a7f37,stroke-width:2px
classDef runtime stroke:#0969da,stroke-width:2px
class PERM,SHELL,CAP,HOOKS,MANIFEST,DRIFT gate
class PIPE,RUN,AUDIT,CI runtime
Permission presets
[permissions] preset expands into a base policy before any [permissions.tools] overrides apply:
| Preset | Default mode | Use when |
|---|---|---|
cautious (aka always-ask) | Prompts on nearly everything, including meta-tools like castellan_discover_tools | First install, unfamiliar plugin, shared/untrusted repo |
balanced (aka write) | Allows read-ish tools, asks before writes/mutations | Daily-driver operator loop once you trust the plugin set |
permissive (aka yolo) | Allows by default | CI, sandboxed containers, fully scripted recipes |
Prompt mode UX
PermissionMode::Prompt is resolved through a host permission prompter seam — not a silent deny.
| Surface | Behavior |
|---|---|
castellan run on a TTY | Interactive [y/N] on stderr for each prompted tool |
castellan run non-TTY | Fail closed (deny) with a clear reason + PermissionDenied event |
| Scripted / CI | Set CASTELLAN_PERMISSION_PROMPT=allow to auto-allow (audited via reason/events), or =deny to force deny |
ACP permissions/request | Returns needs_prompt: true for the editor client to present UI |
ACP session/prompt engine path | Same finalize seam; headless default deny unless CASTELLAN_PERMISSION_PROMPT=allow |
Prompt decisions always emit observability (PermissionDenied / ToolDecision) — never a silent drop.
How operators use it
1. Check the current runtime governance posture:
castellan doctor --json | jq '.stages[] | select(.name=="governance_manifest")'
2. Run the manifest guardrail check (fast, no network, what CI runs):
castellan drift-check
3. Set a permission preset for daily-driver operation:
castellan config set permissions.preset balanced
4. Override a single tool’s policy without changing the whole preset:
[permissions.tools]
castellan_propose_mutation = "deny"
castellan_deposit_signal = "allow"
5. Cap spend and risk for a session in castellan.toml:
[governance]
session_spend_cap_usd = 12.5
max_risk_score = 0.85
denied_paths = ["/secret", "~/.ssh"]
6. Record every governed decision to an audit trail for a run:
castellan run --goal "reach target" --plugin shell --audit .castellan/audit.jsonl
Recipes
When onboarding a new repo or plugin, start with preset = "cautious" and loosen to balanced once you’ve watched a few runs and trust the shell/tool surface.
When running in CI or a disposable container, use preset = "permissive" but keep [shell] denied_commands non-empty — permissive changes the default, not the explicit denylist.
When you need a paper trail for a specific run (compliance, debugging a bad tool call), always pass --audit <path> — governance decisions aren’t persisted anywhere else by default.
When a PR touches CI, manifest.rs, or lint/toolchain config, run castellan drift-check locally before pushing — the same check gates CI and failures there block merge.
Failure paths / troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
castellan drift-check fails locally but code looks fine | A guardrail file (CI workflow, manifest.rs, toolchain pins) drifted from what the manifest expects | Read the check_id/message in the failure; fix the guardrail file, don’t suppress the check |
| Tool call unexpectedly denied | preset is cautious, or a [permissions.tools] override sets deny | Check castellan doctor --json for the active preset; add an explicit allow override if appropriate |
| Prompted tool denied with “no prompter” / “auto-denied” | Non-TTY run without CASTELLAN_PERMISSION_PROMPT=allow, or headless ACP engine path | Run on a TTY, set CASTELLAN_PERMISSION_PROMPT=allow for CI, or set the tool to allow in [permissions.tools] |
| Shell plugin command blocked | Matches [shell] denied_commands or denied_patterns | Confirm the command is actually safe, then adjust the denylist — don’t bypass via a wrapper script |
| Session aborts with a spend/risk error | session_spend_cap_usd or max_risk_score exceeded | Raise the cap deliberately in castellan.toml, or investigate why the run is spending/risking more than expected |
| Hook command fails the whole operation | Hook script exited non-zero | Hook commands must exit 0; fix the script or remove the hook if it’s non-critical |
Related
- Governance policies — CI guardrail manifest, PR requirements, single source of truth
- Agent hooks —
castellan.tomllifecycle hooks and multiplexer agent reporting castellan.tomlreference — full[governance],[shell],[permissions]key lists- CLI reference
- Cursor setup
Governance policies
Policies that keep Castellan’s codebase and guardrails from drifting apart.
Verification pipeline
.github/workflows/ci.yml
│
├── scripts/drift-guard.sh → castellan drift-check (manifest)
└── scripts/verify.sh → fmt, clippy, hack, test, build, smoke
CI must call ./scripts/drift-guard.sh then ./scripts/verify.sh only. Do not add duplicate lint or test steps to the workflow — that creates anti-drift debt.
Single source of truth
| Concern | Canonical location |
|---|---|
| Required checks list | crates/castellan-governance/src/manifest.rs |
| Check enforcement | castellan drift-check (also invoked by drift-guard.sh) |
| Full verification | scripts/verify.sh |
| Code style | Engineering guide |
| License / advisory policy | deny.toml |
When adding a new required check:
- Add a
ManifestCheckentry tomanifest.rs. - Implement validation in
run_drift_check. - If the check belongs in the full suite, add it to
verify.sh. - Note the change in your PR description (required).
PR requirements
PRs that touch any of the following must include a ## Guardrails section in the description explaining what changed and why:
scripts/verify.shscripts/drift-guard.sh.github/workflows/**crates/castellan-governance/src/manifest.rsrust-toolchain.toml,clippy.toml,rustfmt.toml,deny.toml
PRs that change Rust style conventions must update the engineering guide in the same PR.
Drift-check command
cargo run -p castellan-cli -- drift-check
# or, after install:
castellan drift-check
Exits non-zero when any VERIFICATION_MANIFEST expectation fails. Use locally before pushing when editing guardrail files.
Python deprecation
The python/ tree is deprecated and packages were removed. python/README.md must retain a deprecation notice — drift-check enforces this when the directory exists.
Review expectations
- Guardrail changes: one maintainer approval minimum.
- Manifest changes: confirm
castellan drift-checkand./scripts/verify.shboth pass in CI. - No
allow(dead_code)in crate sources without removing the check from manifest (not permitted).
Related
- Agent hooks — multiplexer lifecycle reporting
- CLI reference —
castellan drift-check
Agent hooks
Castellan supports two hook surfaces: governance lifecycle hooks (configured in castellan.toml) and multiplexer agent lifecycle reporting (socket API for editor agents).
Governance lifecycle hooks (castellan.toml)
Configure deterministic shell hooks in layered config (castellan.toml, ~/.castellan/config.toml, .castellan/local.toml):
[[hooks.hooks]]
event = "SessionStart"
command = "./scripts/hooks/session-start.sh"
[[hooks.hooks]]
event = "PreToolUse"
command = "./scripts/hooks/pre-tool.sh"
[[hooks.hooks]]
event = "PostToolUse"
command = "./scripts/hooks/post-tool.sh"
[[hooks.hooks]]
event = "SessionEnd"
command = "./scripts/hooks/session-end.sh"
Accepted event / kind values: SessionStart, SessionEnd, PreToolUse, PostToolUse (snake_case aliases also work).
Each command receives a single JSON payload line on stdin:
| Event | Payload fields |
|---|---|
SessionStart | goal_id, plugin |
SessionEnd | goal_id, status |
PreToolUse | tool, argv, agent_id |
PostToolUse | tool, argv, success |
Invocation paths
| Path | When hooks run |
|---|---|
castellan run | SessionStart/SessionEnd on episode boundaries; PreToolUse/PostToolUse on MCP tool calls and shell plugin execution via ToolGovernancePipeline |
castellan run --rlm | Same session hooks; mux delegate steps use nested sub_harness panes |
| Shell plugin | PreToolUse/PostToolUse with tool: "shell" and argv in payload |
Hook commands must exit 0; non-zero exits fail the surrounding operation.
Prove-before-execute ([governance.plan_verify])
Optional Guardians-shaped gate (Meijer / metareflection pattern — not Universalis-the-language).
When enabled, high-risk tools (shell / exec by default) require a verified workflow_plan
certificate in the tool args envelope before side effects. Missing or failing verification ⇒ deny
(fail closed). Composes under PermissionPolicy: Deny still short-circuits; plan verify does not
replace Allow / Deny / Prompt.
[governance.plan_verify]
enabled = true
# high_risk_tools = ["shell", "exec"]
# allowlisted_tools = ["shell", "exec", ...]
Supply the plan on shell/MCP args as workflow_plan (or tool_workflow): ordered steps with
symbolic refs (no live payloads). Research: docs/superpowers/research/2026-07-13-universalis-agent-proofs.md.
Demo: docs/demo/plan-prove-before-execute.sh.
This is tool-plan safety on the PreToolUse path — distinct from evolve drift / Φ / immune gates
on topology genomes. When plan verify runs on the MCP tool hot path, outcomes emit
PlanVerifyPassed / PlanVerifyDenied on the live EventSink (CLI --json, mux bridge, ACP)
so observers see prove-before-execute without reading audit JSONL alone.
Multiplexer agent lifecycle (socket API)
Agents (Claude Code, Codex, Cursor) can report lifecycle state to the multiplexer without relying on screen heuristics.
Socket API
{"id":"1","method":"pane.report_agent","params":{
"pane_id": "w1:p1",
"agent": "claude",
"state": "working",
"source": "hook"
}}
States: idle, working, blocked, done, unknown.
When source is hook, screen heuristics are ignored until the pane is reset.
Shell hook example
Install into your agent wrapper (~/.config/castellan/hooks/herdr-agent-state.sh — historical script name):
#!/usr/bin/env bash
# Usage: herdr-agent-state.sh <pane_id> <agent> <state>
PANE_ID="${1:?pane_id}"
AGENT="${2:?agent}"
STATE="${3:?state}"
SOCKET="${CASTELLAN_SOCKET:-$HOME/.config/castellan/castellan.sock}"
printf '%s\n' "{\"id\":\"hook\",\"method\":\"pane.report_agent\",\"params\":{\"pane_id\":\"$PANE_ID\",\"agent\":\"$AGENT\",\"state\":\"$STATE\",\"source\":\"hook\"}}" \
| nc -U "$SOCKET"
Claude / Codex / Cursor integration
| Agent | Suggested hook point |
|---|---|
| Claude Code | Wrap claude in a shell function; call hook on tool-approval and turn-complete |
| Codex | Export CODEX_HOOK_CMD pointing to the script above |
| Cursor | Use cursor-agent lifecycle env callbacks if available; else poll is fallback |
CLI wait helper
castellan herd wait --pane w1:p1 --status idle --timeout-ms 60000
Equivalent to events.wait with pane_agent_status_changed.
Environment
| Variable | Purpose |
|---|---|
CASTELLAN_SOCKET | Override default ~/.config/castellan/castellan.sock |
CASTELLAN_ENV=1 | Set in spawned pane shells (detect castellan-managed sessions) |
Related
Plugin architecture
A plugin is a goal environment adapter: it encodes goals into runnable tasks, verifies observations against goals, and scores outcomes for evolution. Plugins are what make Castellan’s coordination and evolution loops generic — the scheduler, substrate, and evolve pipeline never know anything about gridworlds or shell commands specifically; they only know the CastellanPlugin trait.
If you’re integrating a new environment, debugging why fitness looks wrong for your plugin, or deciding between install and link while iterating, this page is the reference.
The trait
#![allow(unused)]
fn main() {
#[async_trait]
pub trait CastellanPlugin: Send + Sync {
fn name(&self) -> &'static str;
fn encode_task(&self, goal: &Goal) -> TaskDescriptor;
async fn verify_goal(&self, goal: &Goal, observation: &serde_json::Value) -> AgentReport;
fn fitness(&self, goal: &Goal, report: &AgentReport) -> FitnessVector;
fn tools(&self) -> Vec<ToolSpec> { vec![] }
}
}
| Method | Called when | Produces |
|---|---|---|
name() | Registry lookup, episode environment_fingerprint | Stable plugin identifier |
encode_task(goal) | Run start | Initial TaskDescriptor — what the scheduler dispatches first |
verify_goal(goal, observation) | Every tick after an observation lands | AgentReport with a NextAction (complete / retry / delegate / fail) |
fitness(goal, report) | Episode end | FitnessVector — plugin-grounded score consumed by castellan evolve |
tools() | Registry init | Optional ToolSpec list the plugin exposes to MCP-connected agents |
Supporting traits, for narrower integrations:
GoalVerifier— verify-only surface, used by RLM loops (castellan run --rlm) that don’t need the full plugin lifecycle.
Coordination hooks (optional, provided via context at runtime — not part of the trait itself):
PluginContext— blackboard read/write, budget snapshot.CoordinationContext— zone list, perception radius,deposit_signal/read_signal.
Anatomy: lifecycle
flowchart LR
REG[register] --> ENC[encode_task]
ENC --> SCHED[scheduler run]
SCHED --> VER[verify_goal]
VER --> EP[episode snapshot]
EP --> EVOLVE[evolve fitness]
classDef runtime stroke:#0969da,stroke-width:2px
classDef evolve stroke:#d97706,stroke-width:2px
class REG,ENC,SCHED,VER runtime
class EP,EVOLVE evolve
- Register —
PluginRegistry::builtin()loads built-ins plus any manifestplugin.tomlon disk. - Encode —
encode_task(goal)produces the initialTaskDescriptorpayload the scheduler dispatches. - Run — the scheduler enqueues the task in the plugin’s primary zone; the host executes it; observations flow back into the blackboard/field.
- Verify —
verify_goalreturns anAgentReportcarrying aNextAction(complete, retry, delegate, or fail). - Episode — the runtime writes
.castellan/episodes/<goal-id>.json, including the plugin’sfitness()output. - Evolve —
castellan evolvereads the episode corpus; the plugin name seeds the archive’senvironment_fingerprintlookup for future--seed-archiveruns.
Current built-in plugins
| Plugin | Kind | LLM required | Description |
|---|---|---|---|
| gridworld | Simulator | No | Deterministic 5×5 grid; agents navigate to a target; boids-style coordination deposits |
| shell | Executor | No | Bounded subprocess (argv, cwd, timeout); exit code + output verification |
Plugin manifest (plugin.toml)
name = "gridworld"
description = "Deterministic 5x5 grid navigation simulator"
version = "0.1.0"
[zones]
primary = "grid"
default = ["grid", "goal"]
[blackboard]
slots = ["state", "last_observation"]
Manifests live at crates/castellan-plugins/<name>/plugin.toml. The registry scans that tree at init — you never hand-edit a central registry.rs to add a plugin.
How operators use it
1. List what’s registered:
castellan plugin list
2. Inspect a plugin’s manifest, zones, and blackboard schema:
castellan plugin info gridworld
3. Scaffold a new plugin from the template:
castellan plugin new myplugin
castellan plugin list
castellan run --goal "your goal" --plugin myplugin
4. Iterate on a plugin without a full reinstall — symlink instead of copy:
castellan plugin link --from ~/src/my-plugin
cargo build --release -p castellan-cli # rebuild to register
# edit ~/src/my-plugin/src/lib.rs, rebuild — no re-link needed
5. Install a plugin from git (for sharing/distribution):
castellan plugin install --git https://github.com/example/castellan-gridworld-extra.git
cargo build --release -p castellan-cli # rebuild to register
6. Uninstall:
castellan plugin uninstall myplugin
install deletes the copied source tree; link only removes the symlink and leaves your source directory intact.
Link vs install
install | link | |
|---|---|---|
| Source location | Copied into crates/castellan-plugins/<name> | Symlinked — edits in the original directory are live |
| Iteration | Re-install after every change | Rebuild only (cargo build) |
| Uninstall | Deletes the copy | Removes the symlink; source directory intact |
| Best for | Distribution, committing to the repo | Active plugin development |
castellan plugin list reports "source": "builtin" | "install" | "link" per plugin so you can tell at a glance how each one got there.
Extension points
| Extension | Location |
|---|---|
| Plugins | castellan-plugin-api, castellan-plugins |
RunHost backends | castellan-runtime::RunHost, castellan-rah, castellan-herdr |
| Evolve hooks | castellan-evolve — GepaHook, EpisodeLogProposer |
| Governance | castellan-governance — VERIFICATION_MANIFEST |
| Multiplexer | castellan-multiplexer — pane deposits → observations |
Recipes
When building a new environment adapter, start from castellan plugin new myplugin rather than hand-rolling the manifest — the template wires up [zones]/[blackboard] correctly and registers automatically on rebuild.
When your plugin’s fitness always comes back flat, check fitness(goal, report) against the actual AgentReport.next_action values you’re returning from verify_goal — a fitness function that ignores NextAction variants will look identical across very different outcomes.
When two plugins need to share coordination state, give them overlapping default zones deliberately in each plugin.toml, but keep primary zones distinct so scheduling doesn’t collide.
When distributing a plugin to teammates, publish it as a git repo and have them castellan plugin install --git <url> rather than sharing a local path — link is a dev-loop convenience, not a distribution mechanism.
Failure paths / troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
unknown plugin: <name> from castellan run | Plugin not registered (missing plugin.toml, or not rebuilt after install/link) | castellan plugin list to confirm registration; rebuild after install/link |
castellan plugin new output doesn’t show up in plugin list | Registry discovery happens at build time via build.rs, not at runtime | Run cargo build --release -p castellan-cli after scaffolding |
| Linked plugin edits don’t take effect | Forgot to rebuild after editing source | cargo build — link makes the source live, not the compiled binary |
| Plugin’s episodes never seed an archive entry | environment_fingerprint mismatch between evolve and run (different plugin name or goal shape) | Confirm name() is stable and goal text matches what evolve saw |
verify_goal never returns Complete | Plugin logic bug, or observation shape doesn’t match what verify_goal expects | Add tracing/log output inside verify_goal; replay the episode with castellan replay to inspect observations tick-by-tick |
Related
- Coordination model — zones, blackboard, and how plugin deposits reach the scheduler
- Topology evolution — how plugin fitness feeds mutation gating
castellan pluginreference- CLI reference
CLI overview
Castellan is a single binary (castellan) with subcommands for goal runs, evolution, multiplexer control, MCP, and diagnostics.
Precedence
Configuration resolves in this order (highest wins):
- CLI flags — e.g.
--goal,--plugin,--json - Environment variables — e.g.
CASTELLAN_MUX_AUTO,CASTELLAN_SESSION_SPEND_CAP_USD - Layered config files — see
castellan.toml
Output modes
| Mode | Flag / entrypoint | Consumer | Reference |
|---|---|---|---|
| Human text | default | Interactive terminal | run |
| NDJSON events | castellan run --json | CI, automation, log pipelines | run |
| MCP stdio | castellan mcp | Cursor, Claude Desktop, other MCP clients | mcp, MCP tools |
| ACP stdio | castellan acp | Editor-class agents (Zed, experimental) | ACP integration |
| Socket NDJSON | castellan multiplexer server | Dashboard, remote attach, mux automation | Socket API |
One runnable recipe per mode:
# text — human terminal
castellan run --goal "reach target" --plugin gridworld
# json — typed NDJSON events for CI (episode JSON also lands in .castellan/episodes/)
castellan run --goal "reach target" --plugin gridworld --json | grep episode_end
# mcp — stdio JSON-RPC (tools/list handshake)
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n' | castellan mcp
# socket — multiplexer NDJSON API
castellan multiplexer server &
castellan multiplexer status
# acp — agent client protocol over stdio
castellan acp doctor
Episode JSON is always written to .castellan/episodes/<goal-id>.json on successful runs (separate from --json stdout).
Top-level commands
| Command | Page |
|---|---|
run | run |
evolve | evolve |
remote | remote |
herd | herd — mux / pane UX (historical name; see lexicon) |
plugin | plugin |
mcp | mcp |
telemetry | telemetry |
doctor | doctor |
replay | replay |
swarm-demo, rah-demo | demos — multi-pane / RAH demos |
Other commands: multiplexer, acp, skills, episodes, recipes, drift-check, memory, integration, vitals, genome, dashboard, completions, ipc (deprecated). Run castellan <cmd> --help for flags. Recipes are defined in castellan.toml [[recipes]].
Shell completions
Completions are generated from live clap metadata so they stay in sync with the CLI:
castellan completions bash # bash completion script
castellan completions zsh # zsh completion script
castellan completions fish # fish completion script
Install examples:
# bash (user-local)
mkdir -p ~/.local/share/bash-completion/completions
castellan completions bash > ~/.local/share/bash-completion/completions/castellan
# zsh
castellan completions zsh > "${fpath[1]}/_castellan"
# fish
mkdir -p ~/.config/fish/completions
castellan completions fish > ~/.config/fish/completions/castellan.fish
Re-run after upgrading castellan when subcommands or flags change.
Help snapshots
CLI help is snapshot-tested in CI. After intentional CLI changes:
UPDATE_SNAPSHOTS=1 cargo test -p castellan-cli cli_help_snapshot
See Deployment.
Recipes
castellan run
Scheduler-led goal loop with a plugin verifier. Writes episode JSON to .castellan/episodes/<goal-id>.json.
Usage
castellan run --goal <GOAL> [--plugin gridworld] [--resume [<GOAL_ID>]]
[--rlm] [--max-steps 8]
[--audit <PATH>] [--json] [--mcp] [--model <URL_OR_NAME>]
[--genome <ID>] [--seed-archive] [--archive .castellan/topology_archive.json]
[--write-back] [--no-write-back] [--mux] [--no-mux]
When resuming, --goal is optional (defaults to checkpoint description). Plugin comes from the checkpoint.
Key flags
| Flag | Default | Notes |
|---|---|---|
--goal | required unless --resume | Natural-language goal text |
--plugin | gridworld | Verifier plugin (castellan plugin list) |
--resume | off | Continue from .castellan/checkpoints/ (optional goal UUID; default latest) |
--rlm | off | In-tree RLM verify/act loop |
--max-steps | 8 | Max RLM steps when --rlm |
--json | off | NDJSON castellan-events on stdout |
--mcp | off | Process mcp_tools from observation payload |
--mux | off (explicit) | Force scheduler-led run via in-process multiplexer panes |
--no-mux | off | Rollback for mux-by-default; also CASTELLAN_MUX_AUTO=0 or CASTELLAN_MUX_DISABLE=1 |
--seed-archive | off | Seed topology from evolve archive before run |
--write-back | auto with --seed-archive | Merge fitness into archive after success |
--mux runs via MuxHarnessHost; episode JSON includes pane_tree. Mux is the default when the multiplexer socket is healthy — no CASTELLAN_MUX_AUTO=1 required. Rollback with --no-mux, CASTELLAN_MUX_AUTO=0, or CASTELLAN_MUX_DISABLE=1.
--json events
| Event | Summary |
|---|---|
episode_start | goal_id, plugin, goal |
scheduler_tick | tick, pending |
task_dispatched | tick, task_id, zone |
verify_result | tick, zone, goal_met |
cost_rollup | per-tick usd, tokens, session total_usd, total_tokens |
deposit | zone signal deposit |
archive_write_back | optional genome merge |
episode_end | goal_id, status, fitness, episode_path |
See statusline for dashboard consumption.
Recipes
Gridworld smoke (verify.sh default):
castellan run --goal "reach target" --plugin gridworld
RLM verify/act:
castellan run --goal "reach target" --plugin gridworld --rlm --max-steps 4
Resume mid-goal:
castellan run --goal "reach target" --plugin gridworld
castellan run --resume
Mux topology in episode:
castellan run --goal "reach target" --plugin gridworld --mux
Evolve round-trip:
castellan evolve --episodes 5 --workspace .castellan/episodes
castellan run --goal "reach target" --plugin gridworld --seed-archive --write-back
Related
castellan evolve
Evolve harness topology from episode logs. Optional reflective Python hook; drift gate rejects incompatible mutations.
Usage
castellan evolve [--episodes 10] [--workspace .castellan/episodes] [--hook rust|seed-only] [--summarize]
castellan evolve --plan
castellan evolve --dry-run
castellan evolve --apply-proposal <UUID>
Key flags
| Flag | Default | Notes |
|---|---|---|
--episodes | 10 | Generations to run |
--workspace | .castellan/episodes | Episode JSON corpus directory |
--hook | rust | Proposer hook (seed-only for fixture tests) |
--summarize | off | Write .castellan/episode_summary.json without evolving |
--plan | off | Read-only analysis: emit an evolution plan JSON, zero writes |
--dry-run | off | Persist proposals to .castellan/proposals/, no archive writes |
--apply-proposal | — | Resolve one persisted proposal (drift gate re-checked) |
--summarize compacts the corpus for the proposer (DRY with substrate heatmap). Drift-incompatible candidates are rejected and logged.
Plan vs dry-run vs apply
| Mode | Reads corpus | Writes proposals | Writes archive |
|---|---|---|---|
--plan | yes | no | no |
--dry-run | yes | yes | no |
--apply-proposal | yes | resolves one | on accept |
Use --plan for a pre-mutation analysis turn (incumbent, proposed coordination, pressure summary, drift preview, fitness estimate), then --dry-run + --apply-proposal (or the MCP castellan_propose_mutation / castellan_resolve_mutation pair) for the gated two-phase apply.
Recipes
Standard evolve from local episodes:
castellan run --goal "reach target" --plugin gridworld
castellan evolve --episodes 10 --workspace .castellan/episodes
Summarize only (no mutation):
castellan evolve --summarize --workspace .castellan/episodes
Seed archive then run:
castellan evolve --episodes 10
castellan run --goal "reach target" --plugin gridworld --seed-archive
Related
castellan herd
Multiplexer UX over the in-tree PTY server — attach, status, and pane operations without raw socket JSON. The subcommand name is historical; in prose this is mux / pane control, not an animal metaphor. Verified against HerdCommands in crates/castellan-cli/src/main.rs.
Usage
castellan herd <SUBCOMMAND>
Subcommands
| Subcommand | Flags | Purpose |
|---|---|---|
server | --socket <PATH>, --session <NAME> | Start or attach to the multiplexer server |
status | --socket <PATH>, --notify | Rich agent dashboard JSON (--notify sends a macOS notification when agents are blocked) |
workspaces | --socket <PATH> | List workspaces on the multiplexer |
tabs | --socket <PATH>, --workspace <ID> | List tabs in a workspace (default: focused) |
attach | --socket <PATH>, --pane <ID>, --session <NAME> | Attach terminal UI to a pane (Ctrl+Q to detach); default is focused pane |
wait | --socket <PATH>, --pane <ID>, --status <STATUS> (default idle), --timeout-ms <MS> (default 30000) | Block until an agent reaches a status (events.wait wrapper) |
agent explain | --socket <PATH>, --pane <ID>, --verbose | Explain why a pane has its current status, with matched rules |
agent attach | --socket <PATH>, --takeover, <TARGET> | Attach directly to an agent by name, label, or pane id |
session list | --json | List named session servers |
session attach | --socket <PATH>, <NAME> | Attach to a named session server |
session stop | --json, <NAME> | Stop a named session server (persists snapshot) |
session delete | --json, <NAME> | Delete a named session and its data |
terminal attach | --socket <PATH>, --takeover, <TERM_ID> | Direct attach to a terminal by id |
terminal observe | --socket <PATH>, --pane <ID>, --cols, --rows | Read-only NDJSON terminal.frame stream |
terminal control | --socket <PATH>, --pane <ID>, --takeover, --cols, --rows | Writable NDJSON terminal (frames out, commands on stdin) |
remote | <HOST>, --session, --handoff, --remote-keybindings local|server, --socket | SSH thin-client remote attach |
Run castellan herd <subcommand> --help for the current flag list — this table tracks main.rs at time of writing.
Recipes
Check multiplexer health:
castellan herd status
Attach to a running agent by name:
castellan herd agent attach claude
Explain why an agent looks stuck:
castellan herd agent explain --pane w1:p1 --verbose
Wait for an agent to go idle (scripting):
castellan herd wait --pane w1:p1 --status idle --timeout-ms 60000
Named sessions (multi-project):
castellan herd session list --json
castellan herd session attach my-project
castellan herd session stop my-project
Read-only pane observation (headless monitor):
castellan herd terminal observe --pane w1:p1 --cols 100 --rows 30
Remote mux panes over SSH:
castellan herd remote user@host --session my-project
Daily-driver stack:
castellan multiplexer ensure
castellan herd status
castellan dashboard
Failure paths
| Symptom | Cause | Fix |
|---|---|---|
herd status returns "running": false | No server at the socket path | castellan multiplexer ensure, then retry |
herd wait times out | Agent never reached target status within --timeout-ms | Increase timeout, or herd agent explain --pane <id> to see why it’s stuck |
herd attach shows nothing | Wrong --pane/--session, or pane exited | herd tabs / herd workspaces to enumerate valid ids first |
herd remote fails to connect | SSH auth or ~/.ssh/config alias missing | Test ssh <host> directly before wrapping in herd remote |
| Ctrl+Q doesn’t detach | Terminal emulator intercepts the escape sequence | Use q from castellan dashboard’s agent list instead |
Related
castellan remote
Attach to the in-tree multiplexer and run a governed goal on remote panes.
Usage
castellan remote --goal <GOAL> [--plugin gridworld] [--socket <PATH>]
[--ssh user@host] [--stub] [--audit <PATH>]
Key flags
| Flag | Notes |
|---|---|
--goal | Required goal text |
--plugin | Verifier plugin (default gridworld) |
--socket | Multiplexer NDJSON socket path |
--ssh | Remote host for SSH attach |
--stub | Deterministic stub run (CI smoke) |
--audit | Optional governance audit JSONL |
Governance pipeline (with_governance) applies on the remote hot path.
Recipes
Local stub smoke (verify.sh):
castellan remote --stub --goal "reach target" --plugin gridworld
Remote with audit trail:
castellan remote --goal "refactor auth module" --plugin shell --audit .castellan/audit.jsonl
Related
castellan plugin
List, inspect, scaffold, install, link, and uninstall dynamic plugins.
Usage
castellan plugin list
castellan plugin info <NAME>
castellan plugin new <NAME>
castellan plugin install --from <DIR>
castellan plugin install --git <URL> [--name <NAME>]
castellan plugin link --from <DIR> [--name <NAME>]
castellan plugin uninstall <NAME>
Installed plugins register via plugins.toml and build.rs — no hand-editing registry.rs.
Link vs install
install | link | |
|---|---|---|
| Sources | copied into crates/castellan-plugins/<name> | symlinked — edits are live |
| Iterate | re-install after every change | rebuild only (cargo build) |
| Uninstall | deletes the copy | removes the symlink, sources intact |
castellan plugin list reports "source": "builtin" | "install" | "link" per plugin.
Recipes
List registered plugins:
castellan plugin list
Install from local directory:
castellan plugin install --from ./my-plugin
cargo build --release -p castellan-cli # rebuild to register
Iterate on a plugin without reinstall:
castellan plugin link --from ~/src/my-plugin
cargo build --release -p castellan-cli # rebuild to register
# edit ~/src/my-plugin/src/lib.rs, rebuild — no re-link needed
castellan plugin uninstall my-plugin # removes only the symlink
Install from git:
castellan plugin install --git https://github.com/example/castellan-gridworld-extra.git
Uninstall:
castellan plugin uninstall sample-plugin
Related
castellan mcp
MCP stdio JSON-RPC server exposing stigmergy, memory, and harness tools from the shared registry (12 operational tools + 3 meta tools — see MCP tools reference).
Usage
castellan mcp [--attach <goal_id|auto>]
Methods: initialize, tools/list, tools/call. Same registry as castellan run --mcp live episodes — no drift between editor and headless behavior.
Attach mode (live field instrument)
--attach binds the stdio server to a running castellan run --mcp goal via the
.castellan/live/ session manifest:
--attach autobinds to the current live session;--attach <goal_id>requires that exact goal to be live.- Fails loudly (
attach failed: no live session) when nothing is running — no silent standalone fallback. - Deposits and snapshots route into the live field with
source: "mcp_attach"provenance, which lands in the episode’sinstrument_summary. - Mutation tools (
castellan_propose_mutation,castellan_resolve_mutation) are denied in attach mode; attached clients are instruments, not operators. - If the session ends mid-attach, live tools return an
attach_losterror.
castellan run --goal "demo" --mcp & # session A: live engine
castellan mcp --attach auto # session B: attached instrument
castellan doctor reports the live session state in its live_session stage.
Error responses
| Error | Meaning | Cause |
|---|---|---|
-32601 | Method/tool not found | Typo, or tool not yet added to the registry |
-32602 | Invalid params | Missing/wrong-typed required field — call castellan_describe_tool for the schema |
castellan_search_tools queries are capped at 256 characters.
Recipes
List tools (stdio):
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | castellan mcp
Discovery flow:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"castellan_search_tools","arguments":{"query":"deposit"}}}' \
| castellan mcp
Describe before invoking (avoid -32602):
echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"castellan_describe_tool","arguments":{"name":"castellan_deposit_signal"}}}' | castellan mcp
Wire Cursor: see Cursor setup.
Related
castellan telemetry
Query dashboard telemetry database — sessions, agent transitions, vitals snapshots, and per-tick cost rollups — without a TTY.
Usage
castellan telemetry [--limit 20] [--db <PATH>]
castellan dashboard --history [--limit 20]
Default DB: ~/.config/castellan/telemetry.db.
Key flags
| Flag | Default | Notes |
|---|---|---|
--limit | 20 | Max rows per query |
--db | user config path | Override telemetry database |
Summary JSON includes cost_rollup_count, session_cost_usd, session_tokens, and recent_costs (from governance cost_rollup wire events recorded while the dashboard is open).
Recipes
Headless session list:
castellan telemetry --limit 5
Dashboard history JSON:
castellan dashboard --history --limit 10
Related
castellan doctor
Staged runtime diagnostics: multiplexer, governance, config layers, LLM provider, plugins, evolve archive, latest episode vitals. Verified against DoctorReport::run in crates/castellan-cli/src/doctor_cmd.rs — 12 stages, always run in this order.
Usage
castellan doctor [--workspace .] [--json]
--json does not change exit behavior — doctor always exits non-zero if any stage’s pass is false. Not every stage can fail: config/preset stages (permissions_preset, plan_verify, model_tiers, mux_default) always report informational detail with pass: true. llm_provider fails when provider = "openrouter" and no API key env is set. memory_sync fails when sync is configured without a token env. Only multiplexer_socket, governance_manifest, live_session, plugin_registry, evolve_archive, and episode_vitals can fail for other reasons.
Stages
Order matches DoctorReport::run in doctor_cmd.rs:
| Stage | Can fail | Checks |
|---|---|---|
multiplexer_socket | yes | Default socket exists / accepts connections |
mux_default | no (informational) | Whether mux-by-default is on given socket health + env vars |
governance_manifest | yes | castellan drift-check violations against the verification manifest |
permissions_preset | no (informational) | Active [permissions] preset and tool-override count |
plan_verify | no (informational) | Guardians-shaped [governance.plan_verify] enabled state |
model_tiers | no (informational) | [models] tier config (default/verify/act/plan — scheduler never uses LLM) |
llm_provider | yes (openrouter without key) | Provider, base_url, key_present (never the value), optional headers, TCP reachability |
memory_sync | yes (configured, no token) | [memory.sync] Turso remote-sync health |
live_session | yes | Active live-field session snapshot / idle + instrument corpus note |
plugin_registry | yes | Registry has at least one plugin, matches plugins.toml |
evolve_archive | yes | .castellan/topology_archive.json under --workspace is readable |
episode_vitals | yes | Latest episode under .castellan/episodes has circulation_vitals or wake_stats |
Recipes
Local workspace check:
castellan doctor --workspace .
CI-friendly JSON:
castellan doctor --json | jq '.stages[] | select(.pass == false)'
Human table (default):
STAGE STATUS DETAIL
------------------------------------------------------------------------
multiplexer_socket PASS socket present at /Users/you/.config/castellan/castellan.sock
mux_default PASS mux default on (socket_healthy=true); rollback: --no-mux, ...
governance_manifest PASS verification manifest OK
...
Failure paths
| Stage failing | Detail message pattern | Fix |
|---|---|---|
multiplexer_socket | socket missing at ... (run castellan multiplexer ensure) | castellan multiplexer ensure |
governance_manifest | N drift violation(s) | castellan drift-check for the full list; fix castellan.toml |
llm_provider | provider=openrouter ... key_present=false | export OPENROUTER_API_KEY=... (never commit the key) |
memory_sync | configured without token env | Set the documented Turso token env |
plugin_registry | empty plugin list | Check plugins.toml; castellan plugin list |
evolve_archive | archive missing or invalid | Run castellan evolve at least once, or pass --workspace to the right root |
episode_vitals | no episode JSON in ... | castellan run --goal "..." --plugin gridworld at least once |
Related
castellan replay
Replay a saved episode JSON or audit JSONL file. --continue-run resumes from checkpoint when supported.
Usage
castellan replay <PATH> [--continue-run]
Recipes
Inspect last episode:
castellan replay .castellan/episodes/latest.json
Replay audit trail:
castellan replay .castellan/audit.jsonl
Related
Demo commands
Scheduler and RAH demos for verify.sh and local exploration.
castellan swarm-demo
Multi-pane scheduler-led coordination demo (swarm-demo is the CLI name).
castellan swarm-demo
Expects output with pane_count, mux_host, and sub_harness_depth (verify.sh smoke).
castellan rah-demo
Depth-3 RAH pane spawn demo — multiplexer + topology binding.
castellan rah-demo
Expects "pane_tree_depth": 3 and "episode_pane_tree": true.
Recipe: demo → evolve flywheel
castellan run --goal "reach target" --plugin gridworld --mux > .castellan/episodes/mux-run.json
castellan evolve --episodes 3 --workspace .castellan/episodes
Related
CLI reference
Single binary: castellan. Paths and config use .castellan/ and castellan.toml. MCP tools are named castellan_*.
Start here: CLI overview
| Command | Page |
|---|---|
run | run |
evolve | evolve |
herd | herd — mux / pane UX (historical name) |
remote | remote |
plugin | plugin |
mcp | mcp |
doctor | doctor |
replay | replay |
telemetry | telemetry |
| Demos | demos |
Also: multiplexer, dashboard, statusline, drift-check, acp, skills, episodes, recipes, config, memory, vitals, genome, completions. Run castellan <cmd> --help for flags.
Help snapshots: UPDATE_SNAPSHOTS=1 cargo test -p castellan-cli cli_help_snapshot
castellan.toml reference
Layered configuration for governance, shell policy, hooks, and MCP permissions. Schema source: crates/castellan-governance/src/config.rs.
File locations (merge order)
| Layer | Path | Precedence |
|---|---|---|
| User | ~/.castellan/config.toml | lowest |
| Project | castellan.toml (walk up from cwd) | middle |
| Local | .castellan/local.toml (same repo as project castellan.toml) | highest |
Later layers override earlier ones. CLI flags and environment variables override all files.
Example
[governance]
session_spend_cap_usd = 12.5
max_risk_score = 0.85
denied_paths = ["/secret", "~/.ssh"]
[shell]
allowed_commands = ["echo", "cargo", "git"]
denied_commands = ["rm", "curl"]
denied_patterns = ["sudo .*"]
max_output_bytes = 65536
[permissions]
default_mode = "ask"
[permissions.tools]
castellan_propose_mutation = "deny"
castellan_deposit_signal = "allow"
[[hooks.hooks]]
event = "PreToolUse"
command = "./scripts/hooks/pre-tool.sh"
Sections
[memory]
| Key | Type | Description |
|---|---|---|
backend | json | sqlite | Durable store backend (default: json) |
path | string | SQLite database path when backend = "sqlite" (default: .castellan/castellan.db) |
mirror_json | bool | Also write .castellan/episodes/*.json alongside SQL (default: true) |
auto_import | bool | Import existing JSON corpus on first SQLite open (default: true) |
checkpoint_interval_ticks | integer | Persist mid-episode checkpoints every N scheduler ticks (default: 0 = episode-end only) |
See Memory architecture.
[governance]
| Key | Type | Description |
|---|---|---|
session_spend_cap_usd | float | Session spend ceiling |
max_risk_score | float | Risk score threshold |
denied_paths | string[] | Paths blocked for tool/file access |
[governance.plan_verify] | table | Opt-in Guardians-shaped prove-before-execute (default off) |
[governance.plan_verify]
| Key | Type | Description |
|---|---|---|
enabled | bool | Require verified workflow_plan for high-risk tools (default false) |
high_risk_tools | string[] | Tools that need a certificate (default shell, exec) |
allowlisted_tools | string[] | Tools permitted inside a workflow plan |
taint_edges | array | Optional { source_ref_prefix, forbidden_sink_tools } constraints |
policy_path | string | Optional TOML file overriding allowlists / taint |
See Agent hooks — prove-before-execute.
[shell]
| Key | Type | Description |
|---|---|---|
allowed_commands | string[] | Allowlist for shell plugin |
denied_commands | string[] | Blocked command names |
denied_patterns | string[] | Regex patterns to block |
max_output_bytes | integer | Cap captured stdout/stderr |
[permissions]
| Key | Type | Description |
|---|---|---|
preset | cautious | balanced | permissive | Approval preset expanded into a base policy |
default_mode | allow | ask | deny | Default MCP tool policy (overrides preset base) |
[permissions.tools] | map | Per-tool overrides |
Permission modes filter tools/list before exposure to MCP clients. castellan doctor reports the active preset.
[coevolution]
Fast-slow co-evolution flywheel. See Evolution.
| Key | Type | Description |
|---|---|---|
fast_remediation | bool | Deposit pheromone hints from failed episodes after each run (default: true) |
slow_evolve_every | integer | Run in-tree castellan evolve every N episodes (0 = off, default) |
slow_evolve_generations | integer | Generations per slow evolve pass (default: 2) |
[models]
| Key | Type | Description |
|---|---|---|
provider | string | Optional LLM provider: openrouter, openai, ollama, or openai_compatible |
base_url | string | OpenAI-compatible API root; defaults by provider (openrouter → https://openrouter.ai/api/v1) |
http_referer | string | Optional HTTP-Referer header (OpenRouter attribution; Castellan default when provider = "openrouter") |
x_title | string | Optional X-Title header (OpenRouter attribution; default Castellan) |
default | string | Fallback model id for all roles (default: mock) |
verify | string | Model for the verify client |
act | string | Model for the act client |
plan | string | Model for the plan client |
Per-role tiers resolve through one OpenAI-compatible client factory (scheduler never routes through an LLM). Auth is env-only — never put API keys in TOML:
| Provider | API key env (first wins) |
|---|---|
openrouter | OPENROUTER_API_KEY, then CASTELLAN_LLM_API_KEY, then OPENAI_API_KEY |
openai / openai_compatible | OPENAI_API_KEY or CASTELLAN_LLM_API_KEY |
ollama | optional (CASTELLAN_LLM_API_KEY / OPENAI_API_KEY); local default URL from OLLAMA_URL + /v1 |
[models]
provider = "openrouter"
default = "openai/gpt-4o-mini"
verify = "anthropic/claude-sonnet-4"
act = "openai/gpt-4o"
# optional overrides:
# base_url = "https://openrouter.ai/api/v1"
# http_referer = "https://github.com/Alphabetsoup16/Flock"
# x_title = "Castellan"
--model / --model-tier on castellan run still override; rebuild CLI with --features llm-http for live HTTP providers.
[[recipes]]
| Key | Type | Description |
|---|---|---|
name | string | Recipe identifier for castellan recipes run <name> |
description | string | Shown by castellan recipes list |
command | string | Shell command executed via sh -c |
cwd | string | Optional working directory |
Recipes inherit [shell] denied_commands / denied_patterns — they are not a policy bypass. Later config layers override recipes by name.
[[recipes]]
name = "gridworld-smoke"
description = "Run gridworld reach-target and write an episode JSON"
command = "castellan run --goal 'reach target' --plugin gridworld --json"
[[hooks.hooks]]
| Key | Type | Description |
|---|---|---|
event | string | Hook event name |
command | string | Shell command to invoke |
See Agent hooks.
Environment overrides
| Variable | Maps to |
|---|---|
CASTELLAN_SESSION_SPEND_CAP_USD | governance.session_spend_cap_usd |
CASTELLAN_MAX_RISK_SCORE | governance.max_risk_score |
CASTELLAN_SHELL_ALLOWED | CSV → shell.allowed_commands |
CASTELLAN_SHELL_DENIED | CSV → shell.denied_commands |
CASTELLAN_DENIED_PATHS | CSV → governance.denied_paths |
CASTELLAN_PERMISSION_MODE | permissions.default_mode |
CASTELLAN_PERMISSION_PROMPT | Headless Prompt resolution: allow | deny (default deny when non-TTY). Legacy shim: FLOCK_PERMISSION_PROMPT |
CASTELLAN_MEMORY_BACKEND | memory.backend |
CASTELLAN_MEMORY_DB | memory.path |
CASTELLAN_MEMORY_MIRROR_JSON | memory.mirror_json |
Full list: Environment variables.
Related
MCP tools
Castellan exposes a progressive-disclosure MCP surface: meta-tools for discovery, compact listings in tools/list, and full schemas on demand via castellan_describe_tool.
Registry: crates/castellan-mcp/src/registry.rs
Stdio server: castellan mcp
Live episodes: castellan run --mcp → CastellanEngine::call_mcp_tool
External instrument: While any scheduler run is active, castellan mcp stdio clients can deposit into the live field via .castellan/live/ (no multiplexer join required). castellan_topology_snapshot returns the published tick + topology from the active session when present.
Live-run instrument
When castellan run is active, the engine publishes a session snapshot under .castellan/live/ each scheduler tick. External MCP clients (Cursor, scripts, another agent) call castellan_deposit_signal through castellan mcp stdio — deposits append to a JSONL queue and are drained into the live PheromoneField on the next tick. Episode JSON records external_mcp_deposit events.
# Terminal A — active run
castellan run --plugin gridworld --goal "reach target"
# Terminal B — external deposit (routes to live session when active.json exists)
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"castellan_deposit_signal","arguments":{"zone":"grid","signal":"coordination","amount":5.0}}}' | castellan mcp
# Topology reflects live tick state
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"castellan_topology_snapshot","arguments":{}}}' | castellan mcp
Demo: bash docs/demo/mcp-live-instrument.sh
Discovery flow
flowchart LR
A[Agent starts] --> B[discover / search tools]
B --> C[describe_tool]
C --> D[Invoke tool]
D --> E[Governance + trace]
classDef runtime stroke:#0969da,stroke-width:2px
classDef substrate stroke:#5b6b8c,stroke-width:2px
classDef gate stroke:#1a7f37,stroke-width:2px
class A,B runtime
class C,D substrate
class E gate
- Discover —
castellan_discover_toolsreturns domain-indexed tool cards (no full schemas). - Search —
castellan_search_toolswithquery(and optionaldomain) narrows by intent. Results are BM25-ranked over tool name, summary, description, and domain; when no whole term matches (partial words), substring matching kicks in. The response reports which via"ranking": "bm25" | "substring". - Describe —
castellan_describe_toolwithnamereturns full schema, preconditions, side effects, examples. - Invoke —
tools/callor livemcp_toolsobservation payload.
Meta-tools are always listed with full schemas. Operational tools in tools/list use compact cards (call castellan_describe_tool for parameters).
Error responses
Discovery tools return "ok": false with stable error codes. Stdio server maps to JSON-RPC -32601 (not found) / -32602 (invalid params). Search queries are capped at 256 characters.
Meta tools
| Tool | Domain | Purpose |
|---|---|---|
castellan_discover_tools | meta | Browse domains and tool names |
castellan_search_tools | meta | BM25-ranked search over catalog |
castellan_describe_tool | meta | Full ToolSpec for one tool |
Operational tools
| Tool | Domain | Summary | Live (--mcp) | Governance |
|---|---|---|---|---|
castellan_deposit_signal | substrate | Deposit pheromone into zone | Yes | pre/post hooks |
castellan_read_signal | substrate | Read signal strength | Yes | read-only |
castellan_read_blackboard | substrate | Read blackboard slot | Yes | read-only |
castellan_topology_snapshot | substrate | Harness topology JSON | Yes | read-only |
castellan_metrics | substrate | Session counters | Yes | read-only |
castellan_multiplexer_status | multiplexer | Dashboard statusline snapshot | Yes | read-only |
castellan_propose_mutation | evolve | Preview harness mutation (diff + drift); does not apply | Yes | preview-only |
castellan_resolve_mutation | evolve | Accept or reject a persisted proposal | Yes | high-impact gate |
castellan_read_resource | substrate | Resolve typed URI (episode://, genome://, pane://, blackboard://) | Yes | read-only |
castellan_recall | memory | Recall durable memory by key | Yes | read-only |
castellan_remember | memory | Store durable memory entry | Yes | pre/post hooks |
castellan_query_field_history | substrate | Query pressure-field history | Yes | read-only |
All operational tools are in LIVE_TOOL_NAMES for castellan run --mcp.
Start the server
castellan mcp
Wire your MCP client to stdin/stdout JSON-RPC. Cursor config example in Cursor setup.
Example: discovery sequence
{"name": "castellan_search_tools", "arguments": {"query": "deposit", "domain": "substrate"}}
{"name": "castellan_describe_tool", "arguments": {"name": "castellan_deposit_signal"}}
{"name": "castellan_deposit_signal", "arguments": {"zone": "grid", "signal": "coordination", "amount": 2.0}}
Example: live episode
Observation payload for castellan run --goal "..." --plugin gridworld --mcp:
{
"mcp_tools": [
{
"tool": "castellan_deposit_signal",
"args": { "zone": "grid", "signal": "coordination", "amount": 2.0 }
}
]
}
Trace JSONL emits mcp_discovery for meta-tools and mcp_tool for operational calls. Governance pre/post hooks apply on the hot path.
Design principles
| Principle | Implementation |
|---|---|
| Progressive discovery | Meta-tools before full schemas |
| Context efficiency | Compact tools/list for operational tools |
| Live parity | Same registry for castellan mcp and castellan run --mcp |
| Description quality | Summary + preconditions + side effects per tool |
Adding a tool
- Add
ToolSpecinregistry.rsand register inall_tool_specs(). - Add handler in
handlers.rsand route incall_tool_with_registry. - Add name to
LIVE_TOOL_NAMESif live episodes should expose it. - Update this page and run
cargo test -p castellan-runtime.
See also MCP overview and Agent guide.
Socket API
Castellan exposes a newline-delimited JSON (NDJSON) API over a Unix domain socket. The in-tree castellan-multiplexer server implements Castellan mux automation; castellan-herdr is the NDJSON client crate (historical name).
Source: crates/castellan-multiplexer/src/api.rs · no external mux binary required
Connect
| Setting | Default |
|---|---|
| Socket path | $CASTELLAN_SOCKET → ~/.config/castellan/castellan.sock → $XDG_RUNTIME_DIR/castellan.sock |
| Protocol | One JSON request per line; one JSON response per line |
| Streaming | events.subscribe and pane.attach may stream additional NDJSON events |
Ensure the server is running:
castellan multiplexer ensure
# or
castellan herd server
Example request:
{"id": 1, "method": "ping", "params": {}}
Example response:
{"id": 1, "result": {"type": "pong", "version": "...", "protocol": "..."}}
Errors use {"id": ..., "error": {"code": "...", "message": "..."}}.
Method catalog
| Method | Purpose |
|---|---|
ping | Health check; returns server and protocol version |
session.snapshot | Full session tree snapshot |
session.attach | Resolve named session → workspace + pane |
workspace.create | Create workspace with label, optional cwd |
workspace.list | List workspaces |
tab.create | Create tab in workspace |
tab.list | List tabs (optional workspace filter) |
tab.focus | Focus tab by id |
pane.split | BSP split (direction, ratio, optional cwd) |
pane.swap | Swap panes |
pane.zoom | Zoom pane (mode: toggle / in / out) |
pane.focus | Focus pane |
pane.list | List panes (optional workspace/tab filter) |
pane.read | Read visible screen text |
pane.send_text | Send text to pane PTY |
pane.send_keys | Send key sequence |
pane.send_input | Send text and/or keys |
pane.resize | Resize PTY rows/cols |
pane.write | Raw write to pane |
pane.attach | Stream pane output (subscription) |
pane.report_agent | Hook: report agent name + state |
pane.clear_agent_authority | Clear agent authority on pane |
agent.get | Agent info for target pane |
agent.send | Send text to agent pane |
agent.list | List agents (optional workspace filter) |
agent.start | Start registered agent in new/split pane |
agent.explain | Diagnose agent state heuristics |
events.subscribe | Stream filtered events |
events.emit | Publish an event onto the server EventBus (used by castellan run / ACP when the socket is healthy) |
events.wait | Block until matching event (timeout) |
worktree.create | Git worktree helper |
worktree.list | List worktrees |
worktree.remove | Remove worktree |
server.live_handoff | Graceful server restart (layout preserved) |
server.stop | Acknowledge stop (server may exit) |
Methods marked Stream return an initial result then additional NDJSON event lines on the same connection.
CLI mapping
| Automation need | CLI |
|---|---|
| Rich status dashboard | castellan herd status |
| Attach to pane | castellan herd attach --pane <id> |
| Wait for agent idle | castellan herd wait --pane <id> --status idle |
| Install agent hooks | castellan integration install <agent> |
| Remote over SSH | castellan remote --ssh user@host |
Agent detection
Screen heuristics detect Claude, Codex, and Cursor agent states (idle / working / blocked). Hooks via pane.report_agent provide authoritative state when integrations are installed.
Live handoff
server.live_handoff writes ~/.config/castellan/sessions/handoff.json, spawns a replacement daemon, and releases the socket. PTY processes are not preserved across handoff — layout and visible text restore only.
Testing
cargo test -p castellan-multiplexer
cargo test -p castellan-herdr
castellan multiplexer ensure
castellan herd status
See Daily driver loop for the operator workflow.
Environment variables
| Variable | Default | Purpose |
|---|---|---|
CASTELLAN_SOCKET | ~/.config/castellan/castellan.sock | Multiplexer Unix socket path |
CASTELLAN_ENV | unset | Set to 1 in castellan-managed pane shells |
RUST_LOG | info | Tracing filter for castellan subcommands |
CASTELLAN_DOCS_URL | https://castellan-docs.pages.dev | llms.txt / sitemap base URL in CI scripts |
OPENROUTER_API_KEY | unset | OpenRouter auth when [models] provider = "openrouter" (never store in TOML) |
OPENAI_API_KEY | unset | OpenAI / compatible HTTP LLM auth |
CASTELLAN_LLM_API_KEY | unset | Fallback LLM API key (any OpenAI-compatible provider) |
CASTELLAN_LLM_BASE_URL | provider default | Override OpenAI-compatible base URL |
CASTELLAN_LLM_MODEL | gpt-4o-mini | Default model id for env-based HTTP clients |
OLLAMA_URL | unset | Optional local LLM base (doctor/tests; Ollama provider appends /v1) |
OLLAMA_MODEL | llama3.2 | Model name for Ollama chat API |
GOVCRAFT_MODE | in_tree_honest_equivalent | Meeting-sched acceptance mode: upstream_meeting_sched, llm_scheduling_hook (historical env name) |
GOVCRAFT_UPSTREAM_TRIALS | 5 | Trial count for --upstream-repro deterministic sweep |
CASTELLAN_PERMISSION_MODE | unset | Override permissions.default_mode (allow / deny / prompt) |
CASTELLAN_PERMISSION_PROMPT | unset (non-TTY → deny) | Headless Prompt resolution: allow or deny. TTY castellan run prompts interactively when unset. Legacy: FLOCK_PERMISSION_PROMPT |
OpenRouter
export OPENROUTER_API_KEY=sk-or-...
# castellan.toml:
# [models]
# provider = "openrouter"
# default = "openai/gpt-4o-mini"
cargo run -p castellan-cli --features llm-http -- doctor
cargo test -p castellan-runtime --features llm-http openrouter_chat_completions_smoke -- --ignored
castellan doctor stage llm_provider reports key_present and TCP reachability — never the key value.
Upstream meeting-sched acceptance (Bet 1)
Optional LLM repro on the upstream meeting-sched fixture (govcraft_acceptance crate — historical name):
export OLLAMA_URL=http://127.0.0.1:11434
cargo test -p govcraft_acceptance --test llm_integration -- --ignored
cargo run -q -p govcraft_acceptance -- --upstream-repro
Without OLLAMA_URL, --upstream-repro emits deterministic baselines and an honest gap table (not the published 48.5% LLM band).
Session / remote
| Variable | Purpose |
|---|---|
SSH_AUTH_SOCK | Used by castellan remote --ssh for agent forwarding |
Hooks
See agent hooks for CASTELLAN_SOCKET in shell hook scripts.
Related
- castellan.toml —
[models]provider seam - CLI reference
- Multiplexer overview
- Stigmergy ablation
Engineering guide
Rust conventions for Castellan contributors. Guardrail enforcement lives in governance policies.
Toolchain
- Pin via
rust-toolchain.toml - Format:
cargo fmt --all - Lint:
cargo clippy --workspace --all-targets -- -D warnings
Verification
| Tier | Command |
|---|---|
| Iterate | ./scripts/verify.sh --fast |
| Before PR / CI | ./scripts/verify.sh (full) |
./scripts/verify.sh --fast
./scripts/verify.sh
castellan drift-check
Details: Verify before PR. Operators who only run Castellan day-to-day should follow Daily driver instead — that path uses castellan drift-check, not ./scripts/verify.sh.
Shared Cargo target (worktrees)
Optional helper for multiple worktrees:
source scripts/dev-env.sh # exports CARGO_TARGET_DIR to a shared directory
verify.sh defaults to $ROOT/target for isolation unless CARGO_TARGET_DIR is already set.
Crate boundaries
| Crate | Responsibility |
|---|---|
castellan-core | Types: Goal, EpisodeLog, topology |
castellan-runtime | Scheduler, engine, thin MCP host glue |
castellan-events | Unified observability schema |
castellan-cli | Operator surface |
castellan-multiplexer | PTY + socket API |
Docs
- Published book (
book/src/) is canonical for operators — castellan-docs.pages.dev docs/holds engineering specs; see docs/INDEX.md- Do not link raw GitHub
docs/blobs from book pages — use relative book links
Agent workflows
| Path | When |
|---|---|
| Frontier sprint | Backlog-driven BL delivery; Full vs Fast path + verify_tier |
| Prewalk | Multi-file work: frontier explores, capped todos, first edit, then cheap executor |
Related
Verify before PR
Contributor gate for Castellan development. Operators running agents day-to-day should use Daily driver (castellan drift-check) instead of this script.
The single verification entry point is ./scripts/verify.sh:
| Tier | Command | Use when |
|---|---|---|
| Fast | ./scripts/verify.sh --fast | Local iteration / residuals — fmt + cargo test --workspace + smoke-docs |
| Full | ./scripts/verify.sh or --full | Before every PR; CI default — clippy, hack, release build, all smokes + moat proofs |
Full tier always retains moat proofs: ablation decay, Φ/convergence gate, mid-run topology drain, Guardians plan_verify. Long orchestration demos (rah/swarm/mux flywheel, field/pane demos) are full-only.
./scripts/verify.sh --fast # while coding
./scripts/verify.sh # before PR
castellan drift-check
CI runs the full script on every PR (.github/workflows/ci.yml). Shared CARGO_TARGET_DIR across worktrees: optional source scripts/dev-env.sh (see engineering).
Documentation style
Guidelines for Castellan book pages.
Voice
- Honest scope — say what Castellan is not early
- Operator-first — copy-paste commands that produce visible artifacts
- No placeholder social proof
- Lexicon: organism + coordination field (genome, vitals, immune, stigmergy, pressure, blackboard, mux panes, dashboard). No animal pack metaphors (herd/flock/rookery) and no feudal/military castle-staff metaphors in body copy. Canonical table: Conventions.
- CLI names: keep
castellan herd/swarm-demoas commands; describe them as mux UX / multi-pane demos in prose - Peer names: no “like X / parity with Y” framing on operator pages; landscape peers stay in Frontier +
COMPETITIVE_MATRIX/VENDOR - Structure: What → Why → How → Failure paths → Next; cross-link instead of repeating essays
Formatting
- One H1 per page (mdbook enforces)
- Use
> **Note**/> **Warning**admonitions - Mermaid diagrams: use
classDef+ semantic classes (runtime,substrate,evolve,gate) — see Design system - No inline
style fill:#...(breaks dark mode) - Max 3 columns in comparison tables
Links
- Relative book links only — never link to raw GitHub
docs/blobs from book pages - Cross-link concepts ↔ reference ↔ integrations
Brand tokens
Hero line (keep in sync with DESIGN.md): Pressure wakes agents — multiplexer, dashboard, and episode flywheel in one binary.
See Design system for the full token reference, typography scale, layout, components, and Mermaid rules.
Quick palette (defined in book/theme/css/custom.css):
--castellan-coordination— teal links and active nav--castellan-evolve— amber evolution accents--castellan-runtime/--castellan-substrate/--castellan-gate— diagram roles
Harness capability docs
- Single source:
docs/harness-manifest.tomldrivesdocs/HARNESS_FEATURES.mdand the landscape table indocs/COMPETITIVE_MATRIX.md. - Update workflow: edit the manifest, then
cargo run -p harness-docs -- generate. - CI:
./scripts/check-harness-doc-parity.sh(viaverify.shsmoke-docs) fails on drift.
Related
Deployment
Audience: maintainers publishing docs and configuring CI.
Documentation site (mdbook → Cloudflare Pages)
Castellan docs are built with mdbook from book/ and deployed on every push to main that touches book/, docs/, or .github/workflows/docs.yml.
| Environment | URL |
|---|---|
| Production | https://castellan-docs.pages.dev |
| Cloudflare Pages | https://castellan-docs.pages.dev |
Local build
./scripts/build-docs-site.sh # canonical — installs mdbook, mermaid, llms.txt, sitemap
# or manually:
cargo install mdbook mdbook-mermaid --locked # once
mdbook build book
open book/book/index.html
Live preview:
mdbook serve book --open
./scripts/verify.sh runs scripts/smoke-docs.sh, which includes mdbook build book.
CLI help snapshots
When CLI flags or subcommands change intentionally:
UPDATE_SNAPSHOTS=1 cargo test -p castellan-cli cli_help_snapshot
verify.sh runs cli_help_snapshot tests (BL-014). MCP tool names must appear in MCP tools — enforced by scripts/check-mcp-doc-parity.sh (BL-015).
CI workflow
Workflow: .github/workflows/docs.yml
- Install Rust toolchain and mdbook
./scripts/build-docs-site.sh→ output inbook/book/cloudflare/wrangler-actionuploadsbook/book/to Pages projectcastellan-docs
Model A (canonical): GitHub Actions direct upload — not Cloudflare Git integration. Keep CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID in GitHub secrets.
gh workflow run docs.yml # manual deploy
Cloudflare Workers Builds (dashboard)
Production health = GitHub Actions → Deploy Docs, not the Cloudflare Workers Builds list. If the dashboard is red with “build token … deleted or rolled” while https://castellan-docs.pages.dev returns 200 and Deploy Docs is green, production is fine — disconnect Model B.
| Symptom | Cause | Fix (operator) |
|---|---|---|
| Build token deleted/rolled | Stale dashboard Git / Workers Builds token | Disconnect Git: Dashboard → Workers & Pages → castellan-docs → Settings → Builds → Disconnect, or run Actions → Disconnect CF Workers Builds |
| Build command fails | Dashboard Git expects a build we do not use | Same — disconnect; rely on Model A (docs.yml + wrangler-action) |
| Wrong build command | Dashboard runs incomplete build | Do not “fix” Model B; keep GitHub Actions as the only publisher |
Automated in-repo: scripts/build-docs-site.sh, .github/workflows/docs.yml (Model A), .github/workflows/cf-builds-disconnect.yml (silence Model B). Root wrangler.toml is optional local assets config — it must not be paired with a connected Git repo in the dashboard.
Operator checklist:
- Confirm
CLOUDFLARE_API_TOKENhas Account → Cloudflare Pages → Edit (Model A) - Confirm
CLOUDFLARE_ACCOUNT_IDis the 32-char account ID (sidebar), not a zone ID - Disconnect Workers Builds Git so only
docs.ymldeploys (workflow above, or dashboard Disconnect) - Re-create the GitHub API token if
docs.ymldeploy returns HTTP 403 — do not confuse that with the dashboard build token
Required GitHub secrets
| Secret | Description |
|---|---|
CLOUDFLARE_API_TOKEN | Account Settings Read + Cloudflare Pages Edit |
CLOUDFLARE_ACCOUNT_ID | 32-char account ID from dashboard sidebar — not a zone ID |
Troubleshooting
| Symptom | Fix |
|---|---|
| Deploy 403 | Token lacks Pages Edit for target account |
| Wrong account ID | Copy from Workers & Pages sidebar, not zone overview |
| Dashboard build token error | Disconnect Git builds; use Model A CI upload only; see Workers Builds table above |
Application / CLI releases
The Castellan CLI is not yet published to crates.io (API still moving). Install from source:
cargo install --path crates/castellan-cli --locked
Semver tags and CI
| Step | Command / workflow |
|---|---|
| Local verify | ./scripts/verify.sh |
| Release prep (dry-run publish) | ./scripts/release-prep.sh [version] |
| Tag push | git tag -a v0.1.0 -m "Release v0.1.0" && git push origin v0.1.0 |
| GitHub Release | .github/workflows/release.yml — verify, artifact upload, changelog body |
MSRV: rust-version = "1.75" in root Cargo.toml. Release workflow pins toolchain 1.75.
Changelog: CHANGELOG.md (Keep a Changelog format).
CI runs ./scripts/verify.sh on every PR and push to main.
Related
Conventions
One page freezes the vocabulary. If a term isn’t here, it’s defined where it’s used.
Canonical lexicon
Castellan docs use two metaphor families — organism (biology of a harness) and field (coordination through the environment). Product name Castellan and Castle Holdings branding stay. Body copy does not lean on animal packs or feudal/military castle staff.
| Prefer | Avoid | Meaning |
|---|---|---|
| Organism / harness as living system | Static config file as the whole story | Genome + circulation + immune gate over a run |
| Genome | Opaque “config blob” without lineage | Named topology snapshot in the evolve archive (parent_genome_id) |
| Circulation / vitals | Vague “health metrics” without the CLI | Tick-level field health (castellan vitals, CirculationVitals) |
| Immune gate / quarantine | “Auto-merge whatever evolve proposes” | Reject or quarantine harmful topology mutations before seeding |
| Evolve / episode flywheel | “Retrain the model” | run → episodes → evolve → drift-check |
| Episode | Vague “session log” | One castellan run goal attempt under .castellan/episodes/ |
| Stigmergy | Agent-to-agent chat / router LLM handoffs | Coordination through deposits in the shared environment |
| Pheromone / deposits | Chat messages between agents | Decaying zone signals agents write; scheduler reads |
| Pressure (field / scheduler) | Manager model picking the next agent | Wake priority from deps + zone signals |
| Blackboard | Free-form shared scratchpad chat | Typed JSON slots plugins declare |
| Mux panes / multiplexer | Bare tmux-script farm metaphors | In-tree PTY workspaces, tabs, panes |
| Dashboard | Control tower / war room | Ratatui operator UI (castellan dashboard) |
| Agents / panes / coordination field | Herd, flock, rookery, swarm-as-animals, “coordinate the herd” | Who runs and where they deposit |
| Plain operator language | Castellan-as-officer, garrison, troops, keep/lord body metaphors | How humans drive the binary |
Command names vs prose
castellan herd and castellan swarm-demo remain CLI subcommand names (historical product surface). In prose, describe what they do — multiplexer UX, multi-pane demo — without animal metaphor. Prefer saying “mux status” / “pane status” next to the literal command:
castellan herd status # pane / mux status (subcommand name is historical)
Crate names (castellan-herdr) and hook scripts (herdr-agent-state.sh) stay as identifiers. Peer product names do not appear in operator prose — see Doc style.
Brand vs body copy
| Keep | Do not expand into body metaphor |
|---|---|
| Product name Castellan | Garrison, troops, lord of the keep |
| Logo: castle keep silhouette (theme asset) | Feudal / military narrative in guides |
| Castle Holdings (org branding) | “Coordinate the herd / flock / rookery” |
Names and paths
| Term | Value | Notes |
|---|---|---|
| Binary | castellan | Single CLI; subcommands below |
| Config file | castellan.toml | Layered — repo root, then .castellan/, then env |
| State directory | .castellan/ | Episodes, archive, proposals, skills |
| Episode logs | .castellan/episodes/ | One JSON per goal run (castellan run) |
| Topology archive | .castellan/topology_archive.json | Genome lineage for castellan evolve / --seed-archive |
| Multiplexer socket | ~/.config/castellan/castellan.sock | Default Unix socket; override with --socket or CASTELLAN_SOCKET |
| Statusline snapshot | ~/.config/castellan/statusline.json | Written by castellan dashboard; read by castellan_read_statusline |
| Telemetry DB | ~/.config/castellan/telemetry.db | castellan telemetry query target |
| MCP tools | castellan_* prefix | e.g. castellan_deposit_signal, castellan_discover_tools — see MCP tools |
| Permission Prompt | TTY ask / non-TTY fail-closed | PermissionMode::Prompt uses a host prompter; see Governance overview and CASTELLAN_PERMISSION_PROMPT |
Note: Paths above are defaults. Named multiplexer sessions (
--session) scope their own socket and persist directory — see Multiplexer overview.
Glossary
| Term | One-liner |
|---|---|
| Episode | A single castellan run goal attempt, logged as JSON under .castellan/episodes/ — field state, topology, wake stats, verdict |
| Topology | The wake graph (nodes, edges, thresholds) agents coordinate through; mutated by castellan evolve |
| Coordination field | Shared environment: blackboard + pheromone zones + pressure scheduler |
| Pressure field | Scheduler wake-priority substrate — pheromone zones + dependency signals decide when a pending task dispatches |
| Stigmergy | Coordination by depositing into the environment instead of negotiating in chat |
| Pheromone / deposit | Named zone signal with decay; agents write, scheduler and peers read within perception radius |
| Blackboard | Typed JSON slots agents read/write instead of talking to each other in natural language |
| Genome | A named topology snapshot in the evolve archive, with lineage (parent_genome_id) |
| Circulation / vitals | Per-run field health (castellan vitals) — cold zones, fever, expression |
| Immune gate | Evolve-time reject/quarantine of harmful topology candidates |
| Quorum | Multi-contributor density gate for colony phase unlock (not a router LLM) |
| Response threshold | Per-node θ for task kinds; surplus drives emergent specialization |
| Corridor / Physarum | Optional trail edges with evaporation + conductance pruning |
| MAP-Elites | Illuminated genome archive by behavior descriptor cells |
| Drift-check | castellan drift-check — validates repo guardrails and topology mutations against the verification manifest |
| RAH (Recursive Agent Harness) | Depth-bounded harness-within-harness spawn (multiplexer pane + topology binding); see castellan rah-demo and Frontier positioning |
| Mux / multiplexer | In-tree PTY server and pane tree; CLI UX includes historical castellan herd |
Page structure (Q&A order)
Prefer this order on concept and operate pages so readers get answers without hunting:
- What — one-sentence definition
- Why — the bet / what it replaces
- How — commands and mechanism
- Failure paths — symptom → cause → fix
- Next — cross-links, not a second essay
Cut redundancy: link Daily driver / Coordination model / Organism model instead of repeating them.
Command surface at a glance
Related
Frontier positioning
Thesis: Castellan owns stigmergic runtime coordination, verified harness recursion (RAH), and episode-grounded topology evolution — not meta-harness wrapping or workflow DSLs.
What Castellan owns
| Wedge | Evidence |
|---|---|
| Pressure-field scheduler | castellan run, stigmergy benches in verify.sh |
| In-tree multiplexer | NDJSON socket API, no external mux binary |
| Topology evolution | castellan evolve, .castellan/topology_archive.json |
| Honest verification | castellan drift-check, plugin-grounded fitness |
What Castellan is not competing on
Use Castellan when coordination geometry must evolve from episode logs — not when you need a drop-in single-agent coding product. Scope detail: What Castellan is / is not.
Maintainer landscape (peer names quarantined here and in docs/COMPETITIVE_MATRIX.md / docs/FRONTIER_POSITIONING.md — not sprinkled through concept pages).
Related
Stigmergy ablation
Method: In-tree honest proxies + upstream meeting-sched fixture. LLM repro optional via OLLAMA_URL.
Summary
| Bench | Stigmergy / pressure-field | Random / conversation | Notes |
|---|---|---|---|
Chain dispatch (stigmergy_ablation) | 5/5 zones | 0/5 zones | Dependency wake via pheromone deposits |
| Gridworld success rate (8 runs) | 100% | 100% | Both configs reach target; chain dispatch is the discriminant |
Meeting-sched proxy (govcraft_acceptance) | 41.7% booking rate | 8.3% booking rate | Discrete slot scheduling with PheromoneField |
| Upstream fixture | deterministic PF / conv | — | fixtures/upstream_easy_seed4242.json |
| Published LLM reference | 48.5% (reference) | 12.6% (reference) | Honest gap unless OLLAMA_URL repro lands in band |
Honest gap table (Bet 1)
| Metric | Measured (in-tree) | Published reference |
|---|---|---|
| Upstream task shape pressure-field (deterministic) | ~80% solve (5 trials, seed 4242–4246) | 48.5% LLM |
| Upstream task shape conversation (deterministic) | ~100% solve (same trials) | 12.6% LLM |
| Slot proxy stigmergy (decay on) | ~41.7% booking rate | — |
| Slot proxy stigmergy (decay off) | ~8.3% booking rate | — |
| Slot proxy random | 8.3% booking rate | — |
Decay ablation: Sequential within-round booking used to mask decay (always filled all 5 slots). Fixed with parallel picks + conflict resolution: decay off collapses to one slot (~8%); decay on spreads (~42%). cargo run -q -p govcraft_acceptance -- --ablation validates ≥5% gap.
Upstream fixture
cargo run -q -p govcraft_acceptance -- --mode upstream_meeting_sched
cargo run -q -p govcraft_acceptance -- --upstream-repro
Fixture: ScheduleGeneratorConfig::easy(), seed 4242, at benches/govcraft_acceptance/fixtures/upstream_easy_seed4242.json.
Honest labeling
- In-tree proxy:
benches/govcraft_acceptanceandbenches/stigmergy_ablationuse CastellanPheromoneField+ scheduler semantics. - Upstream fixture: rooms, attendees, duration slots, overlap predicate (distinct from slot proxy).
- Not reproduced (LLM): 48.5% vs 12.6% unless
--upstream-reprowithOLLAMA_URLmeasures in-band rates. - CI: Slot proxy + ablation in
./scripts/verify.sh; upstream mode opt-in.
Provenance
Upstream meeting-sched task shape and published LLM band cite pressure-field-experiment. Crate/binary names (govcraft_acceptance, GOVCRAFT_*) are historical identifiers.
Reproduce
cargo run -q -p stigmergy_ablation
cargo run -q -p govcraft_acceptance
cargo run -q -p govcraft_acceptance -- --mode upstream_meeting_sched
bash docs/demo/run-ablation.sh
./scripts/verify.sh