Animus plugins are standalone executables that communicate with the host over JSON-RPC 2.0 on stdin/stdout. The canonical framing is one JSON value per line (NDJSON), and current host-side readers also accept pretty-printed multi-line frames from plugins. The host never loads third-party code into the daemon process as a dynamic library.
| Area | Source |
|---|---|
| Wire protocol types | crates/animus-plugin-protocol/src/lib.rs |
| External provider/session protocol crates | animus-provider-protocol, animus-session-backend from launchapp-dev/animus-protocol declared in the workspace Cargo.toml |
| Plugin discovery | crates/orchestrator-plugin-host/src/discovery.rs |
| Plugin host/router | crates/orchestrator-plugin-host/src/host.rs |
| Subject router | crates/orchestrator-plugin-host/src/subject_router.rs |
| Lockfile and signature checks | crates/orchestrator-plugin-host/src/lockfile.rs, crates/orchestrator-plugin-host/src/signature_verifier.rs |
| Provider session bridge | crates/orchestrator-plugin-host/src/session/ |
| Curated default plugins | crates/orchestrator-core/src/plugin_registry.rs |
| Web plugin resolution | crates/orchestrator-cli/src/services/operations/ops_web.rs |
The daemon supervises each plugin as an out-of-process child and talks to it only over stdin/stdout JSON-RPC; no third-party code is linked into the daemon:
graph LR
subgraph DaemonProc["daemon process"]
HOST["PluginHost<br/>(single stdout reader + pending-response map)"]
end
subgraph PluginProc["plugin process (env_clear'd)"]
PLUGIN["plugin binary"]
end
HOST -- "JSON-RPC request (stdin)" --> PLUGIN
PLUGIN -- "response / notification (stdout)" --> HOST
- Compatibility is defined by the wire protocol, not Rust crate linkage.
- Discovery uses
--manifest; runtime use starts withinitialize. - Plugin processes start with
env_clear(). - The host forwards only a small base env allowlist, manifest-declared env vars, and request-local extras.
- Daemon-managed subject, provider, trigger, log-storage, and health-probe
spawns pin plugin cwd to
project_rootso cwd-relative state resolves predictably under.animus/. - Manifest probe failures become discovery warnings so operators can see why a binary was skipped.
- Install state records enough metadata to explain where a plugin came from and which binary hash was approved.
| Kind | Role | Required method family |
|---|---|---|
provider |
Drives an AI provider or CLI | agent/run, agent/resume, agent/cancel, health/check |
subject_backend |
Lists, reads, updates, and optionally watches work subjects | Host routes kind-scoped calls such as <kind>/list, <kind>/get, <kind>/update; control surfaces expose subject/* |
trigger_backend |
Watches external event sources and emits dispatchable trigger events | trigger/watch, trigger/event, trigger/ack |
transport_backend |
Hosts an inbound control transport such as HTTP or GraphQL | transport/start, transport/info or initialize metadata, transport/shutdown, health/check |
web_ui |
Legacy/compat UI wrapper kind | Treated as a browser-facing transport by animus web |
log_storage_backend |
Receives and serves structured log entries | log/entry, log_storage/tail |
custom |
Plugin-owned behavior, usually bridged to MCP | Any plugin-defined method plus lifecycle methods |
task_backend |
Legacy alias | Reserved; new plugins should use subject_backend |
transport_backend plugins that serve a browser UI advertise the $ui/web
capability. animus web open uses that marker to prefer a UI URL over a raw API
URL.
Runtime communication uses JSON-RPC 2.0 over stdio. Canonical host writes stay newline-delimited; host readers also accept pretty-printed multi-line frames from plugins:
- Host spawns plugin.
- Host sends
initialize. - Plugin returns
InitializeResultwith protocol version, plugin info, and capabilities. - Host sends
initialized. - Host sends plugin-specific requests.
- Plugin sends responses and may emit notifications.
PluginHost owns a single reader task for plugin stdout. Responses are matched
to pending JSON-RPC ids and notifications are broadcast to subscribers. This is
documented in Plugin Host Concurrency.
When the host binds a plugin to a project-scoped runtime, the initialize
params include init_extensions.project_binding and may also include
init_extensions.memory_mcp_stdio_command.command. The latter is an optional
host hint for plugins that need to launch the memory MCP sidecar using the same
animus binary path as the parent process. Plugins that do not use it should
ignore it.
discover_plugins(project_root) uses this order:
- Registry entries from
~/.animus/plugins.yaml.- Legacy fallback:
~/.config/animus/plugins.yamlis read only when the new registry is absent andANIMUS_CONFIG_DIRis not set.
- Legacy fallback:
- Project-local plugin binaries in
<project>/.animus/plugins/. - The global install dir:
$ANIMUS_PLUGIN_DIRwhen explicitly set, otherwise~/.animus/plugins/. $ANIMUS_PLUGIN_PATH.$PATH, only when the caller opts into--include-system-path.
Directory scans consider executable names beginning with animus-plugin- or
animus-provider-. Registry entries may point to binaries with any name.
Plugin names are deduplicated by first match in the precedence chain, and a
broken higher-precedence entry still reserves its name so a lower-precedence
copy cannot silently shadow it.
Manifest probes are hardened:
- stdin is closed
- stdout and stderr are capped at 1 MiB
- probe timeout is 5 seconds
- plugin env is scrubbed to the base allowlist
- failures become
DiscoveryWarningrows
animus plugin install writes three forms of state:
| State | Path | Purpose |
|---|---|---|
| Binary | ~/.animus/plugins/ or --plugin-dir |
Executable plugin artifact |
| Registry | ~/.animus/plugins.yaml |
Logical name, binary path, install metadata, signature status |
| Lockfile | <project>/.animus/plugins.lock or ~/.animus/plugins.lock |
Approved version, artifact sha256, optional signature-bundle sha256 |
The lockfile prevents silent binary replacement during later upgrades.
animus plugin lock verify re-hashes installed binaries and reports mismatches.
Supported install sources:
- public GitHub release:
animus plugin install owner/repo[@tag] - local binary:
animus plugin install --path ./target/release/my-plugin - direct HTTPS URL:
animus plugin install --url <url> --sha256 <hex>
Direct URL installs require an expected SHA256. Release installs resolve the
platform-specific asset, verify SHA256 and signature policy, probe --manifest,
then copy the binary into the install directory.
The curated launchapp-dev defaults are defined in
orchestrator-core::plugin_registry so daemon preflight and
animus plugin install-defaults cannot drift.
| Flag | Installs |
|---|---|
| no extra flag | provider plugins: Claude, Codex, Gemini, OpenCode, OAI |
--include-oai-agent |
optional OAI agent provider |
--include-subjects |
default task, requirements, Linear, SQLite, and markdown subject plugins |
--include-transports |
HTTP transport, GraphQL transport, and web UI |
Daemon default preflight requires:
- at least one provider plugin
- at least one subject backend plugin
- a
workflow_runnerplugin - a
queueplugin
Every required role must resolve to an installed plugin before the daemon will start autonomous work; any unsatisfied role aborts startup with the exact install command:
flowchart TD
START["daemon start / run"] --> PF{"preflight<br/>(unless --skip-preflight)"}
PF --> R1{"at least one provider?"}
PF --> R2{"at least one subject_backend?"}
PF --> R3{"workflow_runner present?"}
PF --> R4{"queue present?"}
R1 -->|no| FAIL["abort + print<br/>animus plugin install ..."]
R2 -->|no| FAIL
R3 -->|no| FAIL
R4 -->|no| FAIL
R1 -->|yes| OK
R2 -->|yes| OK
R3 -->|yes| OK
R4 -->|yes| OK
OK["all roles satisfied"] --> BOOT["boot daemon loop"]
FAIL -.->|--auto-install| BOOT
--auto-install installs the curated defaults for unsatisfied roles.
Plugin processes start with env_clear(). The host forwards:
- base variables:
PATH,HOME,USER,SHELL,TERM,TMPDIR,LANG,LC_ALL,RUST_LOG,RUST_BACKTRACE,TZ - variables declared in
PluginManifest.env_required - request-local extras explicitly supplied by the provider session request
If a required declared variable is missing, the host logs a warning but still spawns the plugin. The plugin decides whether startup should fail.
Release-source installs support cosign keyless verification. Policy modes:
| Mode | Behavior |
|---|---|
strict |
Missing, invalid, or untrusted signatures fail the install |
warn |
Verification failures are recorded and warned, but install continues |
disabled |
Signature verification is skipped |
Additional install guards:
- manifest name should match the repository basename unless
--forceis used - unknown GitHub owners trigger trust-on-first-use confirmation
- first-party provider tool names are reserved; installing a plugin that claims
them requires
--allow-shadow-builtin
PluginHost owns one live plugin process:
- a single reader task consumes stdout
- requests are written with JSON-RPC ids
- pending responses are resolved by id
- notifications go to a broadcast channel
- shutdown resolves outstanding requests
The broadcast channel capacity defaults to 256 and can be overridden with:
ANIMUS_PLUGIN_BROADCAST_CAPACITY=512When PluginSpawnOptions.working_dir is set, the host calls
Command::current_dir(...) before spawn. Daemon-owned runtime paths set this to
the resolved project_root; ad-hoc plugin commands such as animus plugin ping and animus plugin call still inherit the caller's cwd unless the command
itself exposes a project-root override.
Provider plugins are driven by orchestrator-plugin-host::session.
- Resolve the requested provider tool.
- Discover
providerplugins. - Spawn and initialize the chosen plugin.
- Send
agent/runoragent/resume. - Forward provider notifications as runner events.
- Keep the active session host so
agent/cancelreaches the same process.
Provider dispatch binds the plugin cwd to the resolved project_root, so
provider-owned state and any child CLI cwd-relative lookups stay anchored to the
repository even when the daemon was started from some other shell directory.
When a provider request fails, the dispatcher routes the HostError through
classify(&HostError) -> RetryDecision to decide whether to respawn or surface
the error. Death-like failures (ConnectionLost, Timeout, ProcessExited,
and unclassified RPC errors) trigger a supervised retry; structured plugin-side
JSON-RPC errors are surfaced to the caller without spending restart budget, and
exceeding the supervisor's restart budget fails with TooManyRestarts:
flowchart TD
REQ["plugin request"] --> RESULT{"result?"}
RESULT -->|Ok| DONE["return result"]
RESULT -->|"Err(HostError)"| CLASSIFY["classify(&HostError)"]
CLASSIFY -->|StructuredError| SURFACE["surface to caller<br/>(no restart spent)"]
CLASSIFY -->|"DeathLike<br/>(ConnectionLost / Timeout / ProcessExited)"| BUDGET{"restart budget left?"}
BUDGET -->|yes| RESPAWN["respawn + retry once"]
BUDGET -->|no| TOOMANY["SupervisorError::TooManyRestarts"]
RESPAWN --> REQ
There is no in-tree provider fallback. Missing providers return a hard error with the install command.
The resolver canonicalizes oai-runner and animus-oai-runner to oai.
Reserved provider names are claude, codex, gemini, opencode, oai, and
oai-runner.
Subject backends expose normalized work items through the
animus-subject-protocol schema. The operator and daemon control surfaces use
generic verbs such as subject/list and subject/get; the control dispatcher
adapts those calls to the kind-scoped plugin methods used by the router.
The host-side router maps subject kinds to initialized plugin hosts:
- exact kind registrations win over globs
- glob registrations use
kind.* - longest glob prefix wins
- duplicate exact kinds and duplicate glob prefixes fail router setup
The current CLI and daemon path relies on these kind-scoped plugin calls:
| Plugin method | Purpose |
|---|---|
<kind>/list |
Return filtered subjects for dispatch or CLI listing |
<kind>/get |
Fetch one subject |
<kind>/create |
Create one subject when the backend supports mutation |
<kind>/update |
Apply a merge-style patch |
<kind>/next |
Return the next runnable subject for a kind |
<kind>/status |
Change a subject's status |
Protocol-level subject backends may also expose schema and watch capabilities,
but current routing decisions are made from the initialized plugin manifest and
capabilities.subject_kinds.
Subject backend spawns also pin cwd to project_root. Backends that persist
state via relative paths such as .animus/subjects/tasks.db therefore resolve
those paths under the repository root instead of under the daemon launch shell.
Trigger plugins are long-lived watchers. The daemon sends trigger/watch with
optional cursor and config, then receives trigger/event notifications. Each
event carries an event id, optional trigger id, optional subject id/kind,
optional action hint, and plugin-owned payload. The host sends trigger/ack
after accepting an event.
The trigger supervisor pins cwd to project_root for the same reason: any
plugin-relative checkpoints or repo-local config reads stay deterministic.
Trigger supervision has a daemon kill switch:
ANIMUS_DAEMON_DISABLE_TRIGGERS=1 animus daemon startanimus web serve and animus web open use normal plugin discovery, then
partition discovered plugins into:
transport_backendweb_ui
The web command starts transport plugins and opens the UI URL advertised by a
plugin with $ui/web capability. There is no in-tree web server. Spawn uses
the plugin manifest's env_required contract, so missing required vars fail
fast before handshake the same way animus plugin info, ping, and call do.
Useful operator commands:
animus plugin list
animus plugin install-defaults --include-subjects --include-transports
animus plugin info <name>
animus plugin call <name> health/check --json '{}'
animus plugin lock verify
animus daemon preflightUse these focused checks when changing plugin behavior:
cargo test -p orchestrator-plugin-host
cargo test -p orchestrator-cli plugin
cargo animus-bin-check