bb Plugin API 1.0 — Introduction
This page is the front door. It shows one small plugin, then explains how the pieces fit. The full member-by-member description is the author's reference.
1. What a plugin is
A bb plugin is one npm package. Its package.json carries a bb block that names the plugin and its entry files. A plugin imports one SDK, @get-bb/plugin-sdk, plus the component kit @bb/ui. It imports nothing else from bb.
A plugin has up to four tiers. Each tier is one source file that runs in one place:
| Tier | File | Runs in | You write |
|---|---|---|---|
| contracts | src/contracts.ts |
everywhere, as data | the schemas of your services, events, and host commands |
| server | src/server.ts |
the bb core process | the service handlers, storage, preferences, events |
| app | src/app.tsx |
the browser | React components that occupy slots in the UI |
| host | src/host.ts |
a child process on a machine | commands that touch the filesystem or spawn processes |
Every first-party feature of bb — the sidebar, the thread page, the timeline, the composer, every provider, every tool — is a plugin of this shape. Your plugin has the same powers.
2. The example: counter
The plugin keeps one number. It exposes get and bump as a service, shows the number in the sidebar footer, and lets agents bump it as a tool.
2.1 package.json
{
"name": "@bb-local/counter",
"version": "0.1.0",
"type": "module",
"exports": { "./contracts": { "source": "./src/contracts.ts", "default": "./dist/contracts.mjs" } },
"files": ["dist", "src"],
"bb": {
"id": "counter",
"name": "Counter",
"description": "Count things. Agents can bump the counter as a tool.",
"category": "developer",
"server": "./src/server.ts",
"app": "./src/app.tsx",
"contracts": "./src/contracts.ts",
"provides": { "counter/counter": { "version": "1.0.0" } },
"contributes": {
"database": true,
"cli": { "commands": ["counter"] },
"slots": ["sidebar.footer"],
"settings": { "step": { "type": "number", "label": "Default step", "default": 1 } }
}
},
"dependencies": { "@get-bb/plugin-sdk": "1.0.0-next.0", "@bb/ui": "*", "zod": "4.3.6" },
"peerDependencies": { "react": "^19.0.0" }
}The manifest holds what bb must know before any code runs: the id, the entry files, the service this package provides, the CLI word it claims, the slot it occupies, and one user setting. Everything else is discovered from the built dist/ files.
2.2 src/contracts.ts — define once
import { defineService, method, defineEvent } from "@get-bb/plugin-sdk/contracts";
import type { EventDecl } from "@get-bb/plugin-sdk/contracts";
import { z } from "zod";
export const STEP_KEY = "counter/step";
export const valueSchema = z.strictObject({ value: z.number().int() });
export type Value = z.infer<typeof valueSchema>;
export const counter = defineService({
id: "counter/counter",
version: "1.0.0",
summary: "Keep one number",
cli: { group: ["counter"] },
methods: {
get: method({
summary: "Read the counter",
input: z.strictObject({}),
output: valueSchema,
renderText: (v) => `count ${v.value}`,
}),
bump: method({
kind: "mutation",
summary: "Add to the counter",
input: z.strictObject({ by: z.number().int().min(1).default(1).describe("How much to add") }),
output: valueSchema,
expose: { tool: true },
tool: { presentation: { label: { pending: "Bumping…", completed: "Bumped" }, icon: "Check", intent: "generic" } },
renderText: (v) => `count ${v.value}`,
}),
},
});
export const changed = defineEvent({
name: "counter/changed",
mode: "emit",
payload: valueSchema,
scope: "server",
wire: { to: ["client"], key: null },
});
declare module "@get-bb/plugin-sdk/contracts" {
interface Events { "counter/changed": EventDecl<Value, "emit"> }
}This file is the whole public contract. From these two methods bb derives, with no more code from you:
| Surface | What you get |
|---|---|
| HTTP | GET /api/v1/counter/counter/get, POST /api/v1/counter/counter/bump |
| CLI | bb counter get, bb counter bump --by 3, with --json, help text, and exit codes |
| SDK | sdk.plugins.counter.counter.bump({ by: 3 }), typed |
| Agent tool | counter_counter_bump with a JSON Schema, a label, and an icon |
| Docs | a reference entry and a line in the generated agent skill |
| Command | bump is a command on the bus: other plugins can intercept it (before) or react to it (after) |
A query is a read. A mutation is a write; it runs through the command bus with an actor. Inputs are strict objects; defaults are filled once.
2.3 src/server.ts — provide the service
import { definePlugin, withDefaults } from "@get-bb/plugin-sdk";
import { changed, counter, STEP_KEY } from "./contracts.js";
import { z } from "zod";
const MIGRATIONS = [
"CREATE TABLE counter (id INTEGER PRIMARY KEY CHECK (id = 1), value INTEGER NOT NULL)",
"INSERT INTO counter (id, value) VALUES (1, 0)",
];
export default definePlugin({
async activate(ctx) {
const db = await ctx.storage.openDatabase(MIGRATIONS);
const step = ctx.preferences.define(STEP_KEY, z.number().int().min(1), { scope: "profile", default: 1 });
await ctx.realtime.declare(changed);
const read = async () => {
const row = await db.get("SELECT value FROM counter WHERE id = 1", []);
return { value: Number(row?.["value"] ?? 0) };
};
await ctx.provide(counter, withDefaults(counter, {
get: read,
bump: async ({ by }) => {
const amount = by ?? (await step.get());
const updated = await db.run("UPDATE counter SET value = value + ? WHERE id = 1 RETURNING value", [amount]);
const value = Number(updated.rows[0]?.["value"]);
ctx.log.info("bumped", { value });
await ctx.realtime.publish("counter/changed", { value });
return { value };
},
}));
},
});What happens here:
ctx.storage.openDatabase(migrations)opens the plugin's own SQLite file and runs the statements it has not run yet. Nothing else can see this database.ctx.preferences.define(key, schema, { scope, default })declares the user setting from the manifest as a typed preference.profilescope means it is stored on the server and synced to every client.ctx.realtime.declare(event)registers the event;ctx.realtime.publish(name, payload)sends it to subscribed browsers.ctx.provide(service, handlers)binds the handlers.withDefaultsre-validates every input at the plugin boundary so the handlers see the same shape in-process and in a worker.activateruns once per load. Every registration is an effect; when the plugin reloads or is disabled, bb disposes them in reverse order. You do not track disposers.
2.4 src/app.tsx — occupy a slot
import { definePluginApp, defineQuery, usePreference, useService, KernelError } from "@get-bb/plugin-sdk/app";
import type { PreferenceRef, SlotComponentProps } from "@get-bb/plugin-sdk/app";
import { Button } from "@bb/ui";
import { z } from "zod";
import { counter, valueSchema, STEP_KEY } from "./contracts.js";
import type { Value } from "./contracts.js";
const valueQuery = defineQuery<Record<string, never>, Value>({
key: "value",
fetch: async (_input, client) => valueSchema.parse(await client.query(counter.id, "get", {})),
invalidateOn: [{ name: "counter/changed", key: null }],
});
let stepRef: PreferenceRef<number> | null = null;
function useCounter() {
try { return useService(counter); }
catch (e) { if (e instanceof KernelError && e.code === "service_unavailable") return null; throw e; }
}
function CounterButton(props: SlotComponentProps<"sidebar.footer">) {
const svc = useCounter();
const value = valueQuery.use({});
const [step] = usePreference(stepRef!);
const label = value.status === "success" ? `count ${value.data.value}` : "count …";
return (
<Button variant="outline" size="sm" disabled={svc === null}
onClick={() => { void svc?.bump({ by: step }); props.closeOnMobile(); }}>
{props.compact ? "+" : label}
</Button>
);
}
export default definePluginApp({
setup(app) {
stepRef = app.preferences.define(STEP_KEY, z.number().int().min(1), { scope: "profile", default: 1 });
app.slots.register({ name: "sidebar.footer", kind: "list", scope: "root", order: 50 }, CounterButton);
},
});What happens here:
setup(app)runs at build time (to record what the plugin claims) and at load time in the browser. Write it synchronous.app.slots.register(options, Component)puts a component into a slot.sidebar.footeris alistslot: every occupant renders, ordered byorder. The slot's owner passes the props (compact,closeOnMobile).useService(counter)returns a typed handle to the server service. A call is one HTTP request; the types come from the contract.defineQueryis the data hook.invalidateOnnames the event that refetches it, so a bump from the CLI, from an agent, or from another browser updates this button.usePreference(ref)reads the same setting the server reads.
2.5 Build, install, run
bb plugin build . # writes dist/: manifest.json, contract.json, app.contributions.json, server.mjs, app.mjs, contracts.mjs, meta.json
bb plugin dev . # installs the directory as path:, rebuilds on change, reloads the plugin
bb counter bump --by 2 # count 2
bb counter get --json # {"value": 2}
curl "$BB_SERVER_URL/api/v1/counter/counter/get"An agent in a thread now has a tool counter_counter_bump. The sidebar button shows the new value the moment any of them runs.
2.6 Test it
import { createTestPlugin } from "@get-bb/plugin-sdk/testing";
import server from "./server.js";
import { counter } from "./contracts.js";
const plugin = await createTestPlugin({
manifest: {
id: "counter", name: "Counter", description: "Count things.",
server: "./src/server.ts", contracts: "./src/contracts.ts",
provides: { "counter/counter": { version: "1.0.0" } },
contributes: { database: true },
},
server,
contracts: [counter],
});
const svc = await plugin.inject(counter);
expect(await svc.bump({ by: 2 })).toEqual({ value: 2 });
const tool = plugin.tool("counter_counter_bump");
expect(await tool.execute({ by: 1 }, { actor: { kind: "agent", id: "thread-1" } })).toEqual({ value: 3 });createTestPlugin runs the real loader against an in-memory database. createAppHarness does the same for the app tier in happy-dom. expectDerivedSurfaces(counter, samples) checks that HTTP, CLI, tool, SDK, and docs all derive.
3. How the pieces work
3.1 Services and the container
The core process holds one service container. A plugin provides services by id (counter/counter) and injects other plugins' services by their contract object and a semver range. The manifest declares the edges: requires (the plugin waits until the provider is up) and uses (the plugin re-runs when the provider changes). Plugins never import each other's code, only each other's contracts modules.
3.2 Commands
Every mutation method is a command on the bus, with an actor (human, agent, plugin, or system). A plugin can add before interceptors (validate, rewrite, or veto) and after listeners to any command, including bb's own: thread.create, thread.send, thread.stop, thread.archive, interaction.resolve. This is how a plugin changes what bb does, not only what it shows.
3.3 Events and realtime
A plugin defines events with defineEvent. Events with wire: { to: ["client"] } reach browsers that subscribe to (name, key). On the app side, defineQuery({ invalidateOn }) turns an event into a refetch. bb's own entity changes arrive the same way (kernel/thread.changed, kernel/thread-head.changed, …).
3.4 Preferences and settings
contributes.settings in the manifest gives the user a form in Settings and a bb preferences command. In code, both tiers define the same key with the same schema and default, and read it with preferences.define(...).get() on the server or usePreference(ref) in the app. Scopes: profile (synced), client (this browser), tab, thread.
3.5 Storage
Each plugin owns a key-value store, a SQLite database under ~/.bb/plugins/data/<id>/, a log, and secrets by reference. Nothing is shared between plugins except through services.
3.6 Slots, arbitration, and forks
The UI is one tree of slots. The kernel declares only root; every other slot is declared by the component that occupies its parent. A slot has a kind: single (one winner), list (everyone, ordered), keyed (one winner per key, for example one renderer per timeline row kind), or chain (each occupant wraps the next). For an exclusive slot, the winner is decided by replaces → user pin → priority → plugin id, and the winner receives Original, the component it displaced, so it can wrap or fall back.
bb plugin fork <id> copies a plugin's source under a new id with replaces: <id>. The fork takes the original's slots and services; the original stays installed for revert. Because first-party features are plugins, any part of bb can be forked this way.
3.7 The host tier
Work that must touch a machine — read a workspace, run a process, provision an environment — runs in a host role child process, not in the core. A plugin declares roles in the manifest (rpc, environment-provider, provider-bridge, …), implements them in src/host.ts with defineHostEntry, and calls its own rpc role from the server with ctx.hostClient(contract, name). Paths are contained to the role's scope; every call and signal is validated on both ends.
3.8 Providers
A coding agent (Claude Code, Codex, Pi, an ACP agent) is a provider plugin: a declaration in the manifest plus a provider-bridge host role that speaks the bridge protocol to the agent's process. The bridge emits a small vocabulary of deltas; a generic assembler turns them into bb's events and rows. Each item carries its own presentation (label, icon, detail), and a provider can register a renderer for its own row kinds in the keyed timeline.row slot.
3.9 Composition, install, update
Which plugins run, in what order, with what config, is a composition: a bundled base file, the user's ~/.bb/composition.yaml, and optional --patch overlays. Install sources are npm:, url:, path:, and bundled:. Artifacts are immutable and digest-addressed; an update is a pointer flip with rollback. bb composition dump prints the tree bb booted.
3.10 What the plugin does not do
It does not parse argv, mount routes, register tools, or keep a list of its UI registrations by hand. It does not import React twice: the browser serves one React, one @bb/ui, and one set of Radix primitives through an import map. It does not carry experimental_ names: the surface is locked in api.lock.json and every export ships under its final name.
4. Where to go next
| Want to… | Read |
|---|---|
| see every manifest key, status, and the composition file format | reference §1 |
| define services, events, commands, host commands | reference §2 |
| use the server context: services, commands, events, storage, preferences, agents, host client | reference §3 |
| register slots, routes, panes, commands, preferences, data hooks in the browser | reference §4 |
| find the slot you want to occupy, with its props | reference §5 (slot catalog) |
| write a host role or an environment provider | reference §6 |
| write a provider bridge | reference §7 |
| know what each method yields on HTTP, CLI, SDK, tool, docs | reference §8 |
| test each tier | reference §9 |
| migrate a 0.4 plugin | reference §10 |
| the worked three-tier example, end to end | next/examples/plugins/hello-slot |