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.rowoccupant atacme:commandwithOriginalas the fallback. - Report rate limits to the UI. One
provider.rateLimitsdelta plus aprovider.error { category: "rate_limit" }when a turn is refused. - Ask the user before the agent runs a command. A blocking
interaction/requestwith acommandsubject; core auto-denies it underescalation: "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
- The
providersplugin's registry readscontributes.providers[]from every running plugin's manifest throughkernel/pluginsand joins it with theproviders/hookscandidate byproviderId(plugins/providers/src/server/registry.ts). Users seeacmeinbb provider listand the composer. - On
turn.requestedthe pool askskernel/bridge-driver.threadStart; core fillspluginVersion,generation, the published host artifact,launch, andlimits; the host driver starts theprovider-bridgerole inside the environment and sendsinitialize { protocol: {min: 3, max: 3}, client, plugin, providerIds }. YourinitializeanswersCAPABILITIES; a capability wider than the declaration isBRIDGE_ERROR { rule: "handshake/narrows-only" }. thread/startarrives withSessionParams:workspaceandenvare filled by the host driver, never by core;optionscarries the resolvedmodel,reasoningLevel,permission,instructions,providerOptions. The pool sendsinput: nulland the first prompt ridesturn/start.- The assembler on the host turns each delta into one section 04 event:
turn.openbinds FIFO to the core-minted turn id;item.openmints the item id and copieskey.providerItemIdintopayload.providerItemId; a delta that breaks a grammar rule is dropped with abridge/log warnnaming the rule (§7.5). interaction/requestblocks untilinteraction.resolve; withapprovalEnforcedBy: "runtime"core auto-denies approvals whenpermission.escalation === "deny"(questions never). A bridge that enforces itself callsctx.autoDeny(policy, request).thread/stop { intent: "interrupt" }expects settlement within the stop grace;session.endedsettles the open turn and its itemsinterrupted(no event of its own).shutdownis answered{}first; then every outstanding request getsINTERACTION_INTERRUPTED.
Pitfalls
- Dynamic tools are not live: the pool sends
tools: []and answerstool/callwithsuccess: 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). Readparams.toolsand forward throughctx.toolCallso the bridge is ready, but do not depend on it. - Record mode is spec-only on the driver: the
BB_BRIDGE_RECORDtee ofbridge.v3.jsonl(06 §6.2 item 7) is not inkernel-host/src/bridges/driver.ts, andbb bridge record|replay|conformanceandbb corpus promoteare not mounted. provider-claude-code readsBB_BRIDGE_RECORDitself to write its owndialect.jsonl; do the same at your SDK seam if you want a corpus. The SDK has nobridge/conformancesubpath (§7.12); step 6 says what a plugin can import. - Scope is explicit and strict (§7.5):
item.*,todo,usage,provider.error|warning,unhandledcarryscope: "turn", turnKeyorscope: "thread"; a turn-scoped delta with no open turn becomesprovider.unhandled, an unrequestedturn.opentoo.thread/deltais validated insendThreadDeltasand throws on a bad shape: a bridge bug, not a wire error. turn.boundaryonce per turn; aprovider.error { settlesTurn: true }settles it instead. A quota refusal is exactlyprovider.error { category: "rate_limit", settlesTurn: true }plusprovider.rateLimitswithresetsAtset; the spec'dprovider-retryplugin (§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.closecarriesstatusandapprovalseparately. Acommandsubject withsessionGrant: nullmay not offerallow_for_sessionwith a grant:resolutionMatchesRequestrefusesgrantedthe subject did not carry.- Gates cut both ways:
turn/steeris ungated, so an agent that cannot take input mid-turn answers withsteer: "queue"and holds the steer itself; declaringmaintenance.health: trueobliges aprovider/healthhandler, andsessionRestore: trueobligesthread/resume. An advertised method with no handler answersMETHOD_NOT_FOUND(§7.3). - Use
ctx.childEnv(params.env)for every agent process: it strips the bridge's ownBB_*and overlays the session's (BB_THREAD_ID,BB_SERVER_URL, …).bridgeEnvmay carry noBB_*key (bridge_env_reserved). Text projection forbb thread log(threads/text-renderers) is spec-only; keep a puresrc/text/*.tsbeside the renderer anyway (provider-claude-codesrc/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.