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. profile scope 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. withDefaults re-validates every input at the plugin boundary so the handlers see the same shape in-process and in a worker.
  • activate runs 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.footer is a list slot: every occupant renders, ordered by order. 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.
  • defineQuery is the data hook. invalidateOn names 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