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:
| contribution | trait | what it is |
|---|---|---|
Tool | Tool | something the model can call |
Provider | Provider | a model backend |
Policy | PermissionPolicy | the verdict on a gated call |
Hook | Hook | a handler at the kernel’s lifecycle points |
Context | ContextContributor | what goes into the prompt |
Command | Command | a /name a person or a client runs |
Surface | Surface | a client of the whole host |
Store | SessionStore | where journals live |
Compactor | Compactor | the strategy behind a compaction |
Service | any type, plus an optional WireService | a value another plugin looks up by key |
the …Source variants | ToolSource, CommandSource, ContextSource, ProviderSource, CompactorSource, HookSource | the 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.