Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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![] }
}
}
MethodCalled whenProduces
name()Registry lookup, episode environment_fingerprintStable plugin identifier
encode_task(goal)Run startInitial TaskDescriptor — what the scheduler dispatches first
verify_goal(goal, observation)Every tick after an observation landsAgentReport with a NextAction (complete / retry / delegate / fail)
fitness(goal, report)Episode endFitnessVector — plugin-grounded score consumed by castellan evolve
tools()Registry initOptional 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
  1. RegisterPluginRegistry::builtin() loads built-ins plus any manifest plugin.toml on disk.
  2. Encodeencode_task(goal) produces the initial TaskDescriptor payload the scheduler dispatches.
  3. Run — the scheduler enqueues the task in the plugin’s primary zone; the host executes it; observations flow back into the blackboard/field.
  4. Verifyverify_goal returns an AgentReport carrying a NextAction (complete, retry, delegate, or fail).
  5. Episode — the runtime writes .castellan/episodes/<goal-id>.json, including the plugin’s fitness() output.
  6. Evolvecastellan evolve reads the episode corpus; the plugin name seeds the archive’s environment_fingerprint lookup for future --seed-archive runs.

Current built-in plugins

PluginKindLLM requiredDescription
gridworldSimulatorNoDeterministic 5×5 grid; agents navigate to a target; boids-style coordination deposits
shellExecutorNoBounded 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.

installlink
Source locationCopied into crates/castellan-plugins/<name>Symlinked — edits in the original directory are live
IterationRe-install after every changeRebuild only (cargo build)
UninstallDeletes the copyRemoves the symlink; source directory intact
Best forDistribution, committing to the repoActive 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

ExtensionLocation
Pluginscastellan-plugin-api, castellan-plugins
RunHost backendscastellan-runtime::RunHost, castellan-rah, castellan-herdr
Evolve hookscastellan-evolveGepaHook, EpisodeLogProposer
Governancecastellan-governanceVERIFICATION_MANIFEST
Multiplexercastellan-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

SymptomLikely causeFix
unknown plugin: <name> from castellan runPlugin 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 listRegistry discovery happens at build time via build.rs, not at runtimeRun cargo build --release -p castellan-cli after scaffolding
Linked plugin edits don’t take effectForgot to rebuild after editing sourcecargo build — link makes the source live, not the compiled binary
Plugin’s episodes never seed an archive entryenvironment_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 CompletePlugin logic bug, or observation shape doesn’t match what verify_goal expectsAdd tracing/log output inside verify_goal; replay the episode with castellan replay to inspect observations tick-by-tick