Provider plugins

A provider plugin adds a coding agent to bb. Its declaration tells core what the agent can do without running code; its bridge is a host role (provider-bridge, a child process per environment) that speaks protocol v3 over line-delimited JSON-RPC on stdio and turns the agent's native stream into thread/delta notifications; its server hooks shape per-thread options and the bridge's env; an optional app half overrides how rows render. This page wraps a hypothetical CLI agent, acme-agent --json, that reads prompts on stdin and prints JSON lines.

Use this when

  • Add a new coding agent to bb. Any agent with a scriptable interface (a CLI, an SDK, an HTTP stream) becomes a provider users pick in the composer.
  • Override how your provider's commands render. A keyed timeline.row occupant at acme:command with Original as the fallback.
  • Report rate limits to the UI. One provider.rateLimits delta plus a provider.error { category: "rate_limit" } when a turn is refused.
  • Ask the user before the agent runs a command. A blocking interaction/request with a command subject; core auto-denies it under escalation: "deny".

What you build

Piece Built at dc07292bf Notes
contributes.providers[] (ProviderDeclaration) built the manifest carries it as data; bb plugin build does not inline src/declaration.ts yet, so keep both and pin them equal in a test (provider-claude-code declaration.test.ts)
contributes.hostRoles[] row provider-bridge + host.ts with defineBridge built bridgeHostRole(decl) builds the row shape; the bootstrap hands a bridge no RoleContext
ctx.provide(providerHooks, …, providerFacts(decl)) built in the Stage 2 providers plugin reference §7.1 lists the contract as spec-only in the SDK; as built it is @get-bb/plugin-providers/contracts with five methods: deriveProviderOptions, bridgeEnv, modelCatalog, declaration, capabilities
app.tsx keyed renderers built optional (§7.1)

Steps

1. Write the declaration (src/declaration.ts)

Every field is required (§7.2). id is the flat provider id users see; bridge names the host role row; permissionModes is the subset of accept-edits < auto < full the bridge honors; reasoningLevels share one rank scale across providers; models.catalog: "fallback-only" skips the model/list probe; maintenance gates the sessionless provider/* methods (all off here, so none must be implemented). CAPABILITIES is the handshake answer; keep it beside the declaration so the server's capabilities hook and the bridge's initialize never disagree.

import { defineProviderDeclaration } from "@get-bb/plugin-sdk/bridge";
import type { BridgeCapabilities } from "@get-bb/plugin-sdk/bridge";

export const CAPABILITIES: BridgeCapabilities = { sessionRestore: false, threadArchive: false, threadRename: false, threadGoal: false, fork: "none", approvalEnforcedBy: "runtime", steer: "native" };
export const DECLARATION = defineProviderDeclaration({
  id: "acme", family: "native", bridge: "acme", icon: "provider-acme/acme",
  strings: { name: "Acme Agent", short: "Acme", description: "The acme-agent CLI", modelBrandPrefix: null },
  permissionModes: ["auto", "full"],
  reasoningLevels: [{ id: "low", label: "Low", rank: 20, description: "Quick answers" },
    { id: "high", label: "High", rank: 60, description: "Deliberate answers" }],
  serviceTiers: [], fork: "none", nativeUserQuestion: false, manualCompaction: false,
  maintenance: { health: false, usage: false, installation: false }, visibility: "always",
  features: [], composerActions: [], approvals: ["command"], extensionKinds: [], interactionKinds: [],
  models: { catalog: "fallback-only", fallback: [{ id: "acme-1", label: "Acme 1", description: "Default model",
    reasoningLevels: ["low", "high"], defaultReasoningLevel: "low", serviceTiers: [], isDefault: true }] },
  skills: { scanRoots: [], discover: false }, bridgeOptions: {},
});

2. Manifest

providers[0] is the declaration as JSON; bridge must name a provider-bridge row (§1.4). provides["providers/hooks"] and requires["providers/registry"] are the service edges.

"bb": {
  "id": "provider-acme", "name": "Acme Agent", "description": "Run bb threads with acme-agent", "category": "providers",
  "server": "./src/server.ts", "app": "./src/app.tsx", "host": "./src/host.ts", "contracts": "./src/contracts.ts",
  "provides": { "providers/hooks": { "version": "1.0.0" } }, "requires": { "providers/registry": "^1.0.0" },
  "contributes": {
    "providers": [ { "id": "acme", "family": "native", "bridge": "acme", "...": "the rest of DECLARATION" } ],
    "hostRoles": [ { "role": "provider-bridge", "name": "acme", "launch": { "kind": "module", "export": "acme" } } ],
    "settings": { "apiBase": { "type": "string", "label": "Acme API base URL", "required": false } }, "slots": ["timeline.row"],
    "icons": { "plugin": "./icons/acme.svg", "named": { "acme": "./icons/acme.svg" } }
  }
}

3. The bridge (src/host.ts)

defineBridge needs declarations, initialize, and the five required handlers: thread/start, turn/start, thread/stop, thread/discard, shutdown. Everything else is optional and gated by capabilities. The agent's dialect here: stdin {"type":"prompt","text"} / {"type":"decision","id","allow"}; stdout text, command (asks permission), output, exit, done { usage }, error { code, message }.

import { spawn, type ChildProcess } from "node:child_process";
import { createInterface } from "node:readline";
import { bridgeError, defineBridge, type BridgeContext, type Delta, type ItemKey, type SessionParams } from "@get-bb/plugin-sdk/bridge";
import { defineHostEntry } from "@get-bb/plugin-sdk/host";
import { z } from "zod";
import { CAPABILITIES, DECLARATION } from "./declaration.js";

const line = z.discriminatedUnion("type", [
  z.object({ type: z.literal("text"), text: z.string() }), z.object({ type: z.literal("command"), id: z.string(), command: z.string() }),
  z.object({ type: z.literal("output"), id: z.string(), text: z.string() }), z.object({ type: z.literal("exit"), id: z.string(), code: z.number().int() }),
  z.object({ type: z.literal("done"), usage: z.object({ input: z.number(), output: z.number() }) }), z.object({ type: z.literal("error"), code: z.string(), message: z.string() }),
]);
interface Session { params: SessionParams; child: ChildProcess; providerThreadId: string; turnKey: string | null; msg: ItemKey | null; cmds: Map<string, string>; n: number }
const sessions = new Map<string, Session>();
const send = (s: Session, text: object) => s.child.stdin?.write(`${JSON.stringify(text)}\n`);  const cmdKey = (id: string): ItemKey => ({ providerItemId: id, channel: null, parentRef: null });
/** The close shape wins (§7.5), so a close must carry the full payload again, command text included. */
const commandItem = (s: Session, id: string, status: "pending" | "completed" | "failed", exitCode: number | null) =>
  ({ kind: "command" as const, payload: { command: s.cmds.get(id) ?? "", cwd: s.params.workspace.root, intent: "terminal" as const, output: null, exitCode, durationMs: null, approval: null, status } });

function onLine(ctx: BridgeContext, s: Session, raw: string): void {
  const parsed = line.safeParse(JSON.parse(raw));
  if (!parsed.success) { ctx.raw(s.params.threadId, "unknown", raw); return; }
  const m = parsed.data, threadId = s.params.threadId, turnKey = s.turnKey, deltas: Delta[] = [];
  switch (m.type) {
    case "text":
      if (s.msg === null) {                                    // a command detaches the message stream; reopen a new key
        s.msg = { providerItemId: `msg-${++s.n}`, channel: "message", parentRef: null };
        deltas.push({ kind: "item.open", key: s.msg, item: { kind: "message", payload: { role: "assistant", text: null, status: "pending" } },
          presentation: { label: "Replying", icon: "message" }, scope: "turn", turnKey });
      }
      deltas.push({ kind: "text.delta", key: s.msg, channel: "message", text: m.text, part: null, turnKey });
      break;
    case "command":
      s.msg = null;
      s.cmds.set(m.id, m.command);
      deltas.push({ kind: "item.open", key: cmdKey(m.id), item: commandItem(s, m.id, "pending", null),
        presentation: { label: "Running command", icon: "terminal", title: m.command }, scope: "turn", turnKey });
      void ctx.requestInteraction({ threadId, providerThreadId: s.providerThreadId, turnKey, requestKey: m.id,
        request: { kind: "approval", reason: null, decisions: ["allow_once", "deny"],
          subject: { kind: "command", itemKey: cmdKey(m.id), command: m.command, cwd: s.params.workspace.root, actions: [{ type: "unknown", command: m.command }], sessionGrant: null } },
      }).then((r) => {
        const allow = r.kind === "approval" && r.decision === "allow_once";
        send(s, { type: "decision", id: m.id, allow });
        if (!allow) ctx.sendThreadDeltas(threadId, [{ kind: "item.close", key: cmdKey(m.id), status: "failed", item: commandItem(s, m.id, "failed", null),
          presentation: { label: "Command denied", icon: "terminal", tint: "warning" }, approval: "denied", scope: "turn", turnKey }]);
      });
      break;
    case "output": deltas.push({ kind: "output.delta", key: cmdKey(m.id), text: m.text, turnKey }); break;
    case "exit":
      deltas.push({ kind: "item.close", key: cmdKey(m.id), status: m.code === 0 ? "completed" : "failed", item: commandItem(s, m.id, m.code === 0 ? "completed" : "failed", m.code),
        presentation: { label: "Ran command", icon: "terminal" }, approval: null, scope: "turn", turnKey });
      break;
    case "done":
      deltas.push({ kind: "usage", last: { totalTokens: m.usage.input + m.usage.output, inputTokens: m.usage.input, cachedInputTokens: 0, outputTokens: m.usage.output, reasoningOutputTokens: 0 }, total: null, contextWindow: null, scope: "turn", turnKey });
      deltas.push({ kind: "turn.boundary", status: "completed", error: null, checkpointId: null, claimIfIdle: false, turnKey });
      s.turnKey = null; s.msg = null;
      break;
    case "error":
      if (m.code === "rate_limit") deltas.push({ kind: "provider.rateLimits", rateLimits: { status: "blocked", kind: "unknown", windows: [], resetsAt: Date.now() + 60_000, reachedReason: m.message, overageStatus: null, overageReason: null } });
      deltas.push({ kind: "provider.error", message: m.message, detail: null, category: m.code === "rate_limit" ? "rate_limit" : "unknown", providerCode: m.code, httpStatus: null, willRetry: false, settlesTurn: true, scope: "turn", turnKey });
      s.turnKey = null; s.msg = null;
  }
  ctx.sendThreadDeltas(threadId, deltas);
}

export const acme = defineBridge({
  declarations: [DECLARATION],
  initialize: () => CAPABILITIES,
  methods: {
    "thread/start": async (params, ctx) => {
      const child = spawn("acme-agent", ["--json", "--cwd", params.workspace.root, "--model", params.options.model], { cwd: params.cwd, env: ctx.childEnv(params.env), stdio: ["pipe", "pipe", "pipe"] });
      const s: Session = { params, child, providerThreadId: `acme-${params.threadId}`, turnKey: null, msg: null, cmds: new Map(), n: 0 };
      sessions.set(params.threadId, s);
      child.on("error", (e) => ctx.recovery(params.threadId, { hint: "reinstall", reason: e.message, retryable: false }));
      child.on("exit", () => { if (sessions.get(params.threadId) === s) ctx.sendThreadDeltas(params.threadId, [{ kind: "session.ended" }]); });
      createInterface({ input: child.stdout! }).on("line", (l) => onLine(ctx, s, l));
      ctx.emitForSession(params.threadId, { method: "thread/identity", params: { providerThreadId: s.providerThreadId, sessionRestorable: false } });
      ctx.sendThreadDeltas(params.threadId, [{ kind: "session.reset" }]);
      return { providerThreadId: s.providerThreadId, sessionRestorable: false };
    },
    "turn/start": async (params, ctx) => {
      const s = sessions.get(params.threadId);
      if (!s || s.turnKey !== null) throw bridgeError("BRIDGE_ERROR", s ? "a turn is already running" : `no session for ${params.threadId}`);
      s.turnKey = `t-${++s.n}`;
      const text = params.input.flatMap((p) => (p.type === "text" ? [p.text] : [])).join("\n");
      send(s, { type: "prompt", text: params.options.instructions ? `${params.options.instructions}\n\n${text}` : text });
      ctx.sendThreadDeltas(params.threadId, [{ kind: "input.accepted", clientRequestId: params.clientRequestId, turnKey: s.turnKey },
        { kind: "turn.open", turnKey: s.turnKey, parentRef: null }]);
      return {};
    },
    "turn/steer": async (params, ctx) => {                     // steer: "native" — the agent reads the next prompt line mid-turn
      const s = sessions.get(params.threadId);
      if (!s || s.turnKey === null) throw bridgeError("NO_ACTIVE_TURN", "no running turn to steer");
      send(s, { type: "prompt", text: params.input.flatMap((p) => (p.type === "text" ? [p.text] : [])).join("\n") });
      ctx.sendThreadDeltas(params.threadId, [{ kind: "input.accepted", clientRequestId: params.clientRequestId, turnKey: s.turnKey }]);
      return {};
    },
    "thread/stop": async ({ threadId, intent }, ctx) => {
      const s = sessions.get(threadId);
      if (s && intent === "interrupt") { s.child.kill("SIGTERM"); ctx.sendThreadDeltas(threadId, [{ kind: "session.ended" }]); }
      sessions.delete(threadId); return {};                      // "release" keeps the agent idle; the next turn reuses it
    },
    "thread/discard": async ({ threadId }) => { sessions.get(threadId)?.child.kill("SIGKILL"); sessions.delete(threadId); return {}; },
    shutdown: async () => { for (const s of sessions.values()) s.child.kill("SIGTERM"); sessions.clear(); return {}; },
  },
});
export default defineHostEntry({ roles: { "provider-bridge": { acme } } });

4. Server hooks (src/server.ts)

deriveProviderOptions runs once per thread.create|send|fork (2 s budget) and becomes options.providerOptions; bridgeEnv becomes the bridge child's env (BB_* names are refused); modelCatalog: null means "use the declaration's fallback list" here. Add @get-bb/plugin-providers to dependencies and re-export the contract from src/contracts.ts (export { providerHooks } from "@get-bb/plugin-providers/contracts";): the build pins provides to that module's exports.

import { definePlugin } from "@get-bb/plugin-sdk";
import { providerFacts } from "@get-bb/plugin-sdk/bridge";
import { providerHooks } from "@get-bb/plugin-providers/contracts";
import { CAPABILITIES, DECLARATION } from "./declaration.js";

export default definePlugin({
  async activate(ctx) {
    await ctx.provide(providerHooks, {
      deriveProviderOptions: async ({ actions }) => ({ verbose: actions.includes("verbose") }),
      bridgeEnv: async ({ settings }) => (typeof settings["apiBase"] === "string" ? { ACME_API_BASE: settings["apiBase"] } : {}),
      modelCatalog: async () => null,
      declaration: async () => DECLARATION,
      capabilities: async () => CAPABILITIES,
    }, providerFacts(DECLARATION));
  },
});

5. Override a row (src/app.tsx)

The timeline.row ladder is tool-ui:… → tool:<name> → <providerId>:<kind> → <kind> → * (§5.8); a <providerId>:<kind> key is the only place a provider id appears in a key. Props are { row, providerId, view, expansion, children, Original }. The slot is declared by ui-timeline-view, so register through inject.

import { definePluginApp } from "@get-bb/plugin-sdk/app";
import { createElement, type ComponentType } from "react";
import { z } from "zod";
const rowSchema = z.looseObject({ status: z.string(), payload: z.looseObject({ command: z.string(), exitCode: z.number().nullable() }) });
const originalSchema = z.custom<ComponentType<Record<string, unknown>>>((v) => typeof v === "function");
function AcmeCommandRow(props: Record<string, unknown>) {
  const row = rowSchema.safeParse(props["row"]);
  const Original = originalSchema.safeParse(props["Original"]);
  if (!row.success) return Original.success ? createElement(Original.data, props) : null;   // not ours: fall through
  return <div data-testid="acme-command"><code>{row.data.payload.command}</code> {row.data.payload.exitCode ?? row.data.status}</div>;
}
export default definePluginApp({
  setup(app) {
    app.slots.inject("timeline.row", (slots) =>
      slots.register({ name: "timeline.row", kind: "keyed", scope: "thread", key: "acme:command" }, AcmeCommandRow));
  },
});

6. Run the conformance kit

Run it in a test, in memory, against the real defineBridge object with a fake acme-agent on PATH. runConformance reports every rule pass, fail, or skip; a required rule whose fixture you omit is skip and listed in report.incomplete, which makes report.passed false, so assert passed rather than an empty fail list. What a plugin can import today: @get-bb/plugin-sdk/testing ships createTestPlugin, createAppHarness, and fakeRoleContext and no conformance helper (§9). The kit itself is @bb/bridge-kit/conformance (runConformance, memoryTransport), and @bb/test-kit re-exports it beside storeIntake (§7.14, §9.7); both are private workspace packages, so an out-of-tree plugin vendors the kit or runs this test from a bb checkout.

import { memoryTransport, runConformance, storeIntake } from "@bb/test-kit";   // workspace-only import; see the note above
import type { PromptInput } from "@get-bb/plugin-sdk/bridge";
const text = (t: string): PromptInput[] => [{ type: "text", text: t, mentions: [], visibility: "user" }];
const report = await runConformance({ transport: memoryTransport(acme), declaration: DECLARATION, pluginId: "provider-acme",
  fixture: { prompt: text("hello"), zeroWorkPrompt: text("say nothing"), hangPrompt: text("hang"), approvalPrompt: text("run ls") },
  intake: storeIntake(), timeoutMs: 5_000, stopGraceMs: 500 });
expect(report.passed, JSON.stringify({ failed: report.results.filter((r) => r.status === "fail"), incomplete: report.incomplete })).toBe(true);

What happens at runtime

  1. The providers plugin's registry reads contributes.providers[] from every running plugin's manifest through kernel/plugins and joins it with the providers/hooks candidate by providerId (plugins/providers/src/server/registry.ts). Users see acme in bb provider list and the composer.
  2. On turn.requested the pool asks kernel/bridge-driver.threadStart; core fills pluginVersion, generation, the published host artifact, launch, and limits; the host driver starts the provider-bridge role inside the environment and sends initialize { protocol: {min: 3, max: 3}, client, plugin, providerIds }. Your initialize answers CAPABILITIES; a capability wider than the declaration is BRIDGE_ERROR { rule: "handshake/narrows-only" }.
  3. thread/start arrives with SessionParams: workspace and env are filled by the host driver, never by core; options carries the resolved model, reasoningLevel, permission, instructions, providerOptions. The pool sends input: null and the first prompt rides turn/start.
  4. The assembler on the host turns each delta into one section 04 event: turn.open binds FIFO to the core-minted turn id; item.open mints the item id and copies key.providerItemId into payload.providerItemId; a delta that breaks a grammar rule is dropped with a bridge/log warn naming the rule (§7.5).
  5. interaction/request blocks until interaction.resolve; with approvalEnforcedBy: "runtime" core auto-denies approvals when permission.escalation === "deny" (questions never). A bridge that enforces itself calls ctx.autoDeny(policy, request).
  6. thread/stop { intent: "interrupt" } expects settlement within the stop grace; session.ended settles the open turn and its items interrupted (no event of its own). shutdown is answered {} first; then every outstanding request gets INTERACTION_INTERRUPTED.

Pitfalls

  • Dynamic tools are not live: the pool sends tools: [] and answers tool/call with success: false, "tool <name> is not available in this composition" unless a tool runner is composed (plugins/providers/src/server/pool.ts; README kernel request 6). Read params.tools and forward through ctx.toolCall so the bridge is ready, but do not depend on it.
  • Record mode is spec-only on the driver: the BB_BRIDGE_RECORD tee of bridge.v3.jsonl (06 §6.2 item 7) is not in kernel-host/src/bridges/driver.ts, and bb bridge record|replay|conformance and bb corpus promote are not mounted. provider-claude-code reads BB_BRIDGE_RECORD itself to write its own dialect.jsonl; do the same at your SDK seam if you want a corpus. The SDK has no bridge/conformance subpath (§7.12); step 6 says what a plugin can import.
  • Scope is explicit and strict (§7.5): item.*, todo, usage, provider.error|warning, unhandled carry scope: "turn", turnKey or scope: "thread"; a turn-scoped delta with no open turn becomes provider.unhandled, an unrequested turn.open too. thread/delta is validated in sendThreadDeltas and throws on a bad shape: a bridge bug, not a wire error.
  • turn.boundary once per turn; a provider.error { settlesTurn: true } settles it instead. A quota refusal is exactly provider.error { category: "rate_limit", settlesTurn: true } plus provider.rateLimits with resetsAt set; the spec'd provider-retry plugin (§5.9, not in the slice) would key on that pair; §7.10 defines the categories.
  • approval: "denied" is not a status (§7.6); item.close carries status and approval separately. A command subject with sessionGrant: null may not offer allow_for_session with a grant: resolutionMatchesRequest refuses granted the subject did not carry.
  • Gates cut both ways: turn/steer is ungated, so an agent that cannot take input mid-turn answers with steer: "queue" and holds the steer itself; declaring maintenance.health: true obliges a provider/health handler, and sessionRestore: true obliges thread/resume. An advertised method with no handler answers METHOD_NOT_FOUND (§7.3).
  • Use ctx.childEnv(params.env) for every agent process: it strips the bridge's own BB_* and overlays the session's (BB_THREAD_ID, BB_SERVER_URL, …). bridgeEnv may carry no BB_* key (bridge_env_reserved). Text projection for bb thread log (threads/text-renderers) is spec-only; keep a pure src/text/*.ts beside the renderer anyway (provider-claude-code src/text/task.ts).

See also

  • Reference §7 (bridges), §7.2 (declaration), §7.5§7.8 (deltas, items, presentation, interactions), §7.10§7.12 (recovery, usage, constants), §3.11 (kernel/bridge-driver), §5.8 (row ladder), §10.3 (old → new).
  • plugins/provider-claude-code/src/{declaration,host,server}.ts, app.tsx, src/host/bridge.ts (the built provider this page abstracts); plugins/providers/src/contracts.ts (providerHooks, providers/registry|models|maintenance|interactions|actions|bridges).
  • Guide 10 (host tier) for the process model every role shares.