Skip to article
Browse chapters

Plugins in Rust

The Plugin trait, the eleven contributions a plugin may register, and the worked example to read first.

What a plugin is

A crate that depends on bingo-sdk and nothing heavier, declares a static manifest, and registers contributions synchronously.

#[async_trait]
pub trait Plugin: Send + Sync + 'static {
    fn manifest(&self) -> &'static PluginManifest;

    /// Synchronous, in dependency order. Only registers; does no I/O.
    fn register(&self, registrar: &mut Registrar) -> Result<(), PluginError>;

    /// After every plugin has registered. May spawn tasks.
    async fn start(&self, _host: HostHandle) -> Result<(), PluginError> { Ok(()) }

    async fn stop(&self) -> Result<(), PluginError> { Ok(()) }
}

Three phases, in order, and each does one thing. register may not do I/O — it is called synchronously while the host is being assembled. start is where a plugin spawns tasks, reads a directory or opens a connection. stop gives back whatever start took.

The manifest

static MANIFEST: PluginManifest = PluginManifest {
    id: "bingo.tools.fs",
    version: env!("CARGO_PKG_VERSION"),
    sdk: "^0.1",
    provides: &["tool:Read", "tool:Edit", "tool:Write"],
    requires: &[],
    config: None,
};

id is reverse-dotted. provides and requires are kind:name strings — tool:Read, provider:anthropic, service:bingo.checkpoint. A missing requirement disables the plugin with a notice; it never crashes the host. sdk is a semver requirement checked at boot.

config claims settings keys and says how each merges across layers:

config: Some(ConfigClaim {
    keys: &[
        ("permissions.defaultMode", Merge::Replace),
        ("permissions.allow", Merge::Accumulate),
    ],
    schema,
}),

The kernel never deserialises a plugin’s settings. registrar.config::<T>() hands the merged slice back typed by the plugin that claimed it, so adding a key needs no kernel change — and a key nobody claimed is reported at startup rather than ignored.

What can be registered

Ten kinds of contribution, each behind one sdk trait, plus a late-resolving source for six of them:

contributiontraitwhat it is
ToolToolsomething the model can call
ProviderProvidera model backend
PolicyPermissionPolicythe verdict on a gated call
HookHooka handler at the kernel’s lifecycle points
ContextContextContributorwhat goes into the prompt
CommandCommanda /name a person or a client runs
SurfaceSurfacea client of the whole host
StoreSessionStorewhere journals live
CompactorCompactorthe strategy behind a compaction
Serviceany type, plus an optional WireServicea value another plugin looks up by key
the …Source variantsToolSource, CommandSource, ContextSource, ProviderSource, CompactorSource, HookSourcethe same kinds, resolved late

A source exists because registration is synchronous and some contributions are only knowable after I/O — an MCP server’s tools, a directory’s skills, an external process’s anything. A source is registered synchronously and answers from whatever it has now; answering with nothing is never wrong (ADR-0009). The kernel reads sources at the moment it needs the set: a turn gathers its tools when it starts, the actor consults command sources when a name is not in the static table. A source that blocks holds a turn’s start, so a source answers from a cache and does its I/O elsewhere.

The traits, in brief

Tool — a spec, and a call. Everything else is a default:

fn spec(&self) -> ToolSpec;
fn traits(&self, input: &Value) -> ToolTraits;         // fail closed by default
fn subjects(&self, input: &Value, cwd: &Path) -> Vec<Subject>;  // what a rule matches on
fn confirm(&self, input: &Value) -> Option<String>;    // only a person may decide
fn preview(&self, input: &Value, cwd: &Path) -> Option<Preview>; // reads, never writes
async fn call(&self, input: Value, cx: &ToolContext) -> Result<ToolOutput, ToolError>;

ToolTraits::default() is the fail-closed reading — not concurrency-safe, not read-only, not trusted, and Interrupt::Block. Say otherwise only when it is true. subjects is what the permission grammar matches on: Bash yields commands, Edit yields paths, WebFetch yields urls. preview is what the permission card shows, which makes the card the proposal step for a tool that writes.

Command — a spec, a run, and optional completion. The outcome is one of four: Applied { message }, View { view }, Prompt { text } (which becomes a turn under the command’s own intent), or Record { body } (one completed item in the transcript). A command marked instant runs even during a turn; anything else waits in the queue behind it.

Hook — an id, a matcher, four decision points and four observation points:

async fn on_submit(&self, input: &mut Input, cx: &HookContext) -> HookOutcome;
async fn before_tool(&self, call: &mut ToolCall, cx: &HookContext) -> HookOutcome;
async fn after_tool(&self, call: &ToolCall, out: &ToolOutput, cx: &HookContext) -> HookOutcome;
async fn on_stop(&self, cx: &HookContext) -> HookOutcome;

async fn on_turn(&self, phase: Phase, turn: &TurnId, items: &[Item], cx: &HookContext);
async fn on_compact(&self, phase: Phase, cx: &HookContext);
async fn on_session(&self, phase: Phase, cx: &HookContext);
async fn on_event(&self, frame: &Frame, cx: &HookContext);

HookOutcome is Continue, Deny, Ask, Block or Redirect. There is no Allow. A hook can only ever tighten what happens, never widen it — the gate is the policy’s, and a hook is not it.

ContextContributor — an id, a Placement (System { order }, RoundStart or Barrier) and a contribute returning pieces. The query hands over the whole host, so a contributor can read a session’s extensions or another session entirely.

PermissionPolicy — decide, an optional on_verdict that installs the session-scoped rule a person accepted, and a describe the kernel publishes as ConfigView.plugins[id] whenever it changes. The policy’s own map stays the one fact; the view is its projection.

Provider — an id, a family, endpoint(model) capabilities that fail closed, a stream, and optional count_tokens, models, auth, login and logout.

SessionStore — create, append, replay, list, delete, and acquire/release for the ownership claim (ADR-0005).

Surface — an id, a kind, and a run handed the host. That is the whole of it: a surface holds no session state, folds frames like any other client, and derives its view at render time.

Libraries versus plugins

A plugin may not depend on another plugin. When two plugins need the same code, it moves into a library: a crate declaring [package.metadata.bingo] tier = "library" that registers nothing and depends on bingo-sdk and external crates only. bingo-auth-oauth is the worked case — PKCE, device code, the credential store, single-flight refresh — used by more than one provider plugin without either importing the other.

When two plugins need each other’s behaviour rather than their code, that is a service: a value registered under a string key and looked up through the registry. Across a process boundary the same idea becomes wire services.

The example to read first

crates/bingo-demo-ui is the reference implementation of the three UI lanes and the shape a plugin has when it wants to put something rich on a screen it knows nothing about. It is small, it is off unless --demo-ui turns it on, and it exists to be read.

bingo --demo-ui

Then /board, and the DemoProgress tool. Its middle is compiled as a doc test, so the example in the design docs is one that builds:

/// The block lane: a person reads the board, the model reads the text.
fn block() -> ToolOutput {
    ToolOutput {
        parts: vec![ContentPart::text("3 rows")],
        is_error: false,
        display: Some(Board::default().view()),
    }
}

/// The panel lane: journaled, and back after `--continue`.
async fn panel(host: &HostHandle, session: &SessionId) -> Result<(), KernelError> {
    let view = serde_json::to_value(Board::default().view()).unwrap_or_default();
    host.extend(session, "bingo.demo.ui", "board", view).await
}

/// The live lane: never journaled, gone on a resume, `Null` removes it.
async fn live(host: &HostHandle, session: &SessionId, step: u64) -> Result<(), KernelError> {
    let bar = View::Progress { value: step, total: Some(15), label: Some("cargo test".into()) };
    let payload = serde_json::to_value(bar).unwrap_or_default();
    host.signal(session, "bingo.demo.ui", "progress", payload).await
}

If you would rather not write Rust at all, the same tools and commands cross a process boundary — see cross-process plugins.