Build your first provider
This walks through examples/plugins/echo-provider, the smallest complete provider plugin. It registers a provider called Echo whose bridge answers every prompt by echoing it back, and on the way it touches every surface a provider plugin has.
Before you start #
- A working bb install with at least one enrolled host (the machine the bridge will run on), and Node 22 for the plugin's own tooling.
@get-bb/plugin-sdk0.4.16 or later andzod4. The bridge compiles against@get-bb/plugin-sdk/provider-bridge, which is real schema and helper code that gets bundled into your host artifact, so list the SDK underdependenciesas the echo example does.- Plugin basics: a bb plugin is a package with a
bbblock inpackage.json;bb plugin new <name>scaffolds one,bb plugin install .registers the directory in place, andbb plugin devrebuilds the app and host bundles and reloads on every save.
Never import a private @bb/* workspace package from a plugin. Inside the bb monorepo it would typecheck; an installed plugin cannot resolve it, and the host artifact build rejects it anyway. Everything a provider plugin needs is exported from @get-bb/plugin-sdk and its subpaths: /host, /provider-bridge, /provider-bridge/acp, /provider-bridge/testing, /ai-services, /app, /testing.
The files #
bb-plugin-echo-provider/
package.json the manifest: bb.server and bb.host
server.ts bb.providers.register, settings, a bb tool
host.ts the bb.host artifact: bridge export + host RPC entry
contract.ts the host RPC contract (one trivial method)
src/vocabulary.ts ids, extension-kind schemas, presentations
icons/receipt.svg the one icon the plugin declares (bb.branding.experimental_icons)
src/provider-bridge.ts the bridge
provider-bridge.conformance.test.ts
provider-bridge.stream.test.ts
provider-bridge.parity.test.ts
server.test.ts · host.test.ts · public-sdk-only.test.ts
recordings/echo-agent/turn-tools/ a real recording, replayed by the parity test
1. The manifest #
package.json{
"name": "bb-plugin-echo-provider",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": { "bb": ">=0.0", "bbPluginSdk": ">=0.4.3" },
"bb": {
"name": "Echo provider",
"description": "The third-party canary: a complete agent provider ...",
"branding": {
"icon": "Zap",
"experimental_icons": { "receipt": "./icons/receipt.svg" }
},
"server": "./server.ts",
"host": "./host.ts"
},
"keywords": ["bb-plugin"],
"dependencies": { "@get-bb/plugin-sdk": "workspace:*", "zod": "^4.3.6" }
}
- The plugin id is derived from the package name:
bb-plugin-echo-providerbecomesecho-provider. That id is the namespace of your extension kinds (echo-provider/receipt) and of plugin-defined interaction requests. bb.serveris required; path installs load it as TypeScript directly.bb.hostis required for a provider plugin: declaring a provider without a host artifact fails the plugin load, because the picker entry would exist and no turn could ever run.bb.appis optional. The echo provider ships none; rows render from their declarative presentation. Add one only for a timeline renderer or a custom icon component.bb.branding.iconis the plugin's own mark (a host glyph name or a./SVG path).bb.branding.experimental_iconsis the plugin's own icon vocabulary for rows: a map of declared name → plugin-relative.svg, validated at build and at load. Step 2 uses it.
2. A vocabulary module #
The declaration and the bridge must agree on kind names, schemas and presentations, so the echo plugin keeps them in one plain-data module both sides import. It bundles into the host artifact untouched.
src/vocabulary.ts (excerpt)import type { PluginProviderFallbackModel } from "@get-bb/plugin-sdk";
import {
type DeltaPresentation,
experimental_presentationTitle as presentationTitle,
experimental_withTitle as withTitle,
} from "@get-bb/plugin-sdk/provider-bridge";
import { z } from "zod";
export const ECHO_PLUGIN_ID = "echo-provider"; // from package.json
export const ECHO_PROVIDER_ID = "echo-agent"; // stable: thread rows persist it
export const ECHO_MODEL_ID = "echo-1";
// One literal for the declaration's cold-cache fallback and, with the wire
// `model` id added, the bridge's live model/list answer.
export const ECHO_MODEL = {
id: ECHO_MODEL_ID,
displayName: "Echo 1",
description: "Repeats what it hears.",
supportedReasoningEfforts: [
{ reasoningEffort: "low", description: "Whisper" },
{ reasoningEffort: "medium", description: "Speak" },
{ reasoningEffort: "high", description: "Shout" },
],
defaultReasoningEffort: "medium",
isDefault: true,
} satisfies PluginProviderFallbackModel;
// An ITEM kind: one receipt per echoed prompt. Validated at ingest.
export const ECHO_RECEIPT_KIND = `${ECHO_PLUGIN_ID}/receipt` as const;
export const echoReceiptSchema = z.object({
prompt: z.string(),
itemCount: z.number().int().nonnegative(),
shouted: z.boolean(),
});
// A STATE kind: latest snapshot wins per thread.
export const ECHO_MOOD_KIND = `${ECHO_PLUGIN_ID}/mood` as const;
export const echoMoodSchema = z.object({
mood: z.enum(["cheerful", "bored"]),
turnsEchoed: z.number().int().nonnegative(),
});
// What bb.providers.register declares under `extensionKinds`.
export const echoExtensionKinds = {
receipt: { item: echoReceiptSchema },
mood: { state: echoMoodSchema },
} as const;
// The bag deriveProviderOptions returns; the bridge parses it back.
export const echoProviderOptionsSchema = z.object({
shout: z.boolean(),
model: z.string(),
promptMode: z.enum(["plan"]).nullable(),
});
export const ECHO_GREETING_ENV = "BB_ECHO_PROVIDER_GREETING";
export const ECHO_PROJECT_SKILL_ROOT = ".echo/skills";
// Presentation for the rows the bridge opens. The headline helpers are the
// kit's: the first non-empty line, capped at 160 characters, and no title at
// all when there is nothing to headline.
export function commandPresentation(command: string): DeltaPresentation {
return withTitle(
{
label: { pending: "Running command", completed: "Ran command" },
icon: { glyph: "Terminal" },
},
presentationTitle(command),
);
}
export const NOOP_TOOL_PRESENTATION: DeltaPresentation = {
label: { pending: "Clearing throat", completed: "Cleared throat" },
icon: { glyph: "Toolbox" },
suppress: true, // a low-value row clients collapse by default
};
Notice the shape of an extension-kind declaration: the key is the local name (receipt), the server prefixes your plugin id to form the namespaced kind, and each entry may carry an item schema, a state schema, or both. Schemas are Standard Schema v1 validators; zod 4 schemas qualify. The kit also ships whole rows whose wording never varies by provider (experimental_fileReadPresentation, experimental_searchPresentation, experimental_planStepsPresentation, …); the echo keeps its own so the example reads top to bottom.
Declare an icon and use it on a row
No host glyph says "receipt", so the echo ships one. The manifest above declares it under bb.branding.experimental_icons; the file is a monochrome shape that draws with currentColor:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3h14v18l-2.5-1.5L14 21l-2-1.5L10 21l-2.5-1.5L5 21z"/><path d="M9 8h6"/><path d="M9 12h6"/><path d="M9 16h4"/></svg>
The receipt row names it by its namespaced glyph, "<pluginId>/<name>", in the same icon.glyph field a host glyph goes in:
// The one icon this plugin ships, referenced by its namespaced glyph. The
// server checks at ingest that the glyph names an icon this plugin declares;
// clients draw the SVG tinted with the row's colour, and fall back to the
// per-kind glyph if the plugin is gone.
export const ECHO_RECEIPT_ICON_GLYPH = `${ECHO_PLUGIN_ID}/receipt` as const; // "echo-provider/receipt"
export function receiptPresentation(receipt: EchoReceipt): DeltaPresentation {
return withTitle(
{
label: { pending: "Writing receipt", completed: "Wrote receipt" },
icon: { glyph: ECHO_RECEIPT_ICON_GLYPH },
detail: `Echoed ${receipt.itemCount} item${receipt.itemCount === 1 ? "" : "s"}${receipt.shouted ? ", shouting" : ""}.`,
tint: { light: "#047857", dark: "#6ee7b7" },
},
presentationTitle(receipt.prompt),
);
}
That is the whole feature from the bridge's side: a name, never bytes or a path. The server serves the file hashed from /api/v1/plugins/echo-provider/assets/icons/receipt.svg?h=…, the web timeline paints it as a currentColor mask (the tint still applies), mobile fetches it and draws it tinted with the glyph as its loading fallback, and a row persisted with the name draws the per-kind fallback glyph if the plugin is ever uninstalled. A glyph that names another plugin, or a name you never declared, is replaced at ingest by a provider/unhandled whose reason names the glyph; the conformance rule presentation/icon-namespaced-declared catches that before you ship (step 7). The rules, the validator and the resolution order are in Concepts.
3. The declaration #
The server entry default-exports a factory that receives the plugin API. The echo plugin defines one setting, registers one bb tool, and registers the provider.
server.tsimport type { BbPluginApi } from "@get-bb/plugin-sdk";
import { /* ids, schemas, presentations */ } from "./src/vocabulary.js";
export default function plugin(bb: BbPluginApi) {
bb.settings.define({
shout: { type: "boolean", label: "Shout",
description: "Echo every prompt in upper case.", default: false },
});
// A bb tool the bridge calls during every turn (over item/tool/call).
// Its presentation is resolved by the server into the tool definition
// the bridge receives, and stamped on the call's row.
bb.agents.registerTool({
name: ECHO_STAMP_TOOL_NAME,
description: "Stamp a piece of text with the echo provider's seal.",
parameters: echoStampToolParametersSchema,
presentation: ECHO_STAMP_TOOL_PRESENTATION,
execute: ({ text }) => `stamped: ${text}`,
});
bb.providers.register({
id: ECHO_PROVIDER_ID,
displayName: "Echo",
icon: "Zap",
strings: {
signInHint: "Nothing to sign in to: the echo agent runs offline.",
expiredHint: "Echo sessions never expire.",
installUrl: "https://github.com/get-bb/bb/tree/main/examples/plugins/echo-provider",
brandPrefix: "Echo ",
planModeCopy: "Echo will repeat your plan without running anything.",
iconTint: { light: "#b45309", dark: "#fcd34d" },
},
maintenance: { health: true, usage: false, installation: false },
capabilities: {
supportsServiceTier: true,
supportsNativeUserQuestion: false,
fork: "none",
supportsManualCompaction: false,
supportsThreadArchive: false,
supportsThreadRename: false,
permissionModes: ["accept-edits", "auto", "full"],
reasoningLevels: ["low", "medium", "high"],
},
reasoningLevels: [
{ id: "low", label: "Whisper" },
{ id: "medium", label: "Speak" },
{ id: "high", label: "Shout", description: "Echo with conviction." },
],
serviceTiers: [
{ id: "default", label: "Default" },
{ id: "fast", label: "Fast" },
],
composerActions: ["plan"],
models: { fallback: [ECHO_MODEL] },
env: { passthrough: [ECHO_GREETING_ENV] },
experimental_nativeSkillRoots: { project: [ECHO_PROJECT_SKILL_ROOT] },
deriveProviderOptions(context): EchoProviderOptions {
return {
shout: context.settings.shout === true,
model: context.model,
promptMode: context.promptMode ?? null,
};
},
extensionKinds: echoExtensionKinds,
});
}
bb.providers.register returns { dispose(): void }. You only need the disposer if you re-register at runtime (the ACP plugin does, when its settings change); on reload every registration is replaced wholesale.
Every declaration field #
The full table with types and defaults is in the reference. In prose:
id(required)- 2–64 characters of lowercase letters, digits and
-, starting with a letter or digit. Flat and permanent: thread rows persist it, routes reference it. Ids are collision-rejected: the first live registration wins and a later one from another plugin fails that plugin's load. No id is reserved ahead of time. displayName(required)- 1–80 characters, shown in the picker.
family- Optional grouping key with the same grammar as
id. The ACP agents share"acp". Grouping only; no policy hangs on it. icon- Same grammar as the manifest's
bb.branding.icon: a named host glyph ("Zap") or a plugin-relative SVG path starting with./. A path is served to clients aslogoUrland drawn as acurrentColormask (a monochrome mark follows the theme); a glyph name arrives asicon.glyph. stringssignInHint,expiredHint,installUrl(required inside the object), plus optionalbrandPrefix(stripped from model display names),planModeCopy(banner copy for theplanaction) andiconTint { light, dark }. Core surfaces render this copy instead of keying on your id.maintenance{ health?, usage?, installation? }, each defaulting tofalse. Declares which sessionless requests your bridge answers so the server can skip the rest. The echo bridge declares onlyhealth, and its bridge answersprovider/usagewith method-not-found so declaration and bridge cannot disagree.capabilities(required)- Pre-session facts an external consumer needs before a bridge runs:
supportsServiceTier,supportsNativeUserQuestion,fork("none" | "tip" | "checkpoint"),supportsManualCompaction,supportsThreadArchive,supportsThreadRename,permissionModes(non-empty, from"accept-edits" | "auto" | "full") and the coarsereasoningLevelsladder. The bridge reports the operative truth atinitializeand may narrow these, never widen them. composerActions(required)"plan"and/or"goal", or empty. The skills typeahead is universal and never declared.reasoningLevels,serviceTiers- Labelled picker options
{ id, label, description? }.idis the wire value the bridge receives. Declared lists are the cold-cache fallback;model/listis precise per model. modelsfallback: cold-cache models shown only until the firstmodel/listprobe completes (exactly oneisDefault).scope:"host"when the bridge answersmodel/listfrom account or agent state and ignores the workspace path, so bb probes once per machine;"workspace"(the default) when project configuration can change the answer.env{ passthrough: string[] }. Provider processes are spawned with every inheritedBB_*variable stripped; name the ones your bridge may read (at most 32,[A-Z_][A-Z0-9_]*).extensionKinds- Local name →
{ item?, state? }schemas. Payloads are validated at ingest; a miss persists aprovider/unhandledin the item's place. A kind is accepted only on a thread whose provider this plugin registered, so a bridge emits only its own plugin's kinds. deriveProviderOptions(context)- Called synchronously by the server on every session and turn command with
{ threadId, projectId, model, permissionMode, promptMode?, settings }. Returns the JSON object the bridge receives asoptions.providerOptions. Core never reads it. Secret settings are omitted fromsettingsbecause provider options ride the daemon wire and are persisted. Must be fast; a throw fails the command with your plugin named. experimental_nativeSkillRoots,experimental_nativeCommandRoots,experimental_resolvesNativeRoots- Where the agent keeps its own skills and slash commands (
userandprojectsides, relative to the host home and the workspace; each entry a path or{ path, recursive?, ancestors?, namePrefix?, skipIfManifest? }; at most 32 per side; anabsoluteside is refused), and whether your host entry answersresolveNativeRootsfor the directories only one host can name. See Native roots. experimental_bridgeOptions,experimental_visibility- Immutable JSON (≤ 64 KiB) forwarded opaquely to the bridge on every request, and
"always" | "installed"listing policy."installed"hides the provider until its ownprovider/healthresult is notnot_installed, so it requiresmaintenance.health. The ACP plugin uses both; the echo provider needs neither.
4. The host artifact #
One artifact, two consumers. The daemon's bridge bootstrap imports the artifact and looks for the named export experimental_providerBridge; the daemon's host RPC worker imports the same artifact and looks for the default export. Each attaches through its own bootstrap and owns its own process lifecycle; nothing is shared but the bytes.
import { experimental_defineHostEntry } from "@get-bb/plugin-sdk/host";
import { echoProviderHostContract } from "./contract.js";
export { experimental_providerBridge } from "./src/provider-bridge.js";
export default experimental_defineHostEntry({
contract: echoProviderHostContract,
handlers: {
hostGreeting: (_input, context) => ({
platform: process.platform,
dataDir: context.experimental_paths.dataDir,
}),
},
});
contract.ts
import { defineRpcContract } from "@get-bb/plugin-sdk";
import { z } from "zod";
export const echoProviderHostContract = defineRpcContract({
hostGreeting: {
input: z.object({}).strict(),
output: z.object({ platform: z.string(), dataDir: z.string() }).strict(),
},
});
A provider that needs nothing from the host besides the bridge can skip the default export; the ACP, Codex and Claude Code plugins use it to probe an installed agent, serve AI services, or resolve native roots on the machine.
5. The bridge #
The bridge is an export, not a program. It must not read stdin, parse argv or install signal handlers itself: the daemon's bootstrap owns the process boundary, hands the bridge its plugin-scoped directories, frames stdin with a bounded line reader, and forwards signals. Importing the module starts nothing, which is also what lets tests drive it in-process.
src/provider-bridge.ts (the export)import { experimental_defineProviderBridge } from "@get-bb/plugin-sdk/provider-bridge";
export const experimental_providerBridge = experimental_defineProviderBridge({
handleLine, // one decoded stdin line
start(context) { // optional; once, before the first line
// context: { pluginId, dataDir, tempDir }
},
// onClose() {} stdin closed: the runtime is gone, shut down
// onSigterm() {} onSigint() {}
});
The bridge writes protocol lines to stdout, and nothing else to stdout. The echo bridge keeps one writer, the kit's:
import { createBridgeIo, runBridgeRequest } from "@get-bb/plugin-sdk/provider-bridge";
// The single stdout writer: protocol traffic only, never stray logs. The
// kit's sendResult/sendError answer requests; send carries the rest.
const io = createBridgeIo<{ jsonrpc: "2.0" } & Record<string, unknown>>();
function notify(method: string, params: Record<string, unknown>): void {
io.send({ jsonrpc: "2.0", method, params });
}
function emitDeltas(threadId: string, deltas: ThreadDelta[]): void {
notify(THREAD_DELTA_NOTIFICATION_METHOD, { threadId, deltas });
}
function sendRequest(method: string, params: Record<string, unknown>): string {
outboundRequestCounter += 1;
const id = `echo-req-${outboundRequestCounter}`; // the bridge's own id space
io.send({ jsonrpc: "2.0", id, method, params });
return id;
}
Line handling and hygiene
Request versus response is discriminated on the presence of method, never on result shape. An unknown method answers -32601; invalid params answer -32602 with the issues; a non-JSON line and an unsolicited response-shaped line are ignored and the bridge stays alive. The conformance kit checks each of these.
export function handleLine(line: string): void {
let message: unknown;
try { message = JSON.parse(line); } catch { return; } // non-JSON: ignored
if (typeof message !== "object" || message === null || Array.isArray(message)) return;
const { id, method, params } = message as { id?: unknown; method?: unknown; params?: unknown };
if (typeof method !== "string") { handleResponse(message); return; } // a reply to our own request
if (typeof id !== "string" && typeof id !== "number") return; // notification: ignored
const handler = handlers[method];
if (handler === undefined) {
io.sendError(id, BRIDGE_JSON_RPC_ERRORS.METHOD_NOT_FOUND, `Method not found: ${method}`);
return;
}
// A handler that throws answers an error instead of taking the bridge down;
// a thrown experimental_BridgeRecoveryError answers with error.data.recovery.
runBridgeRequest({
request: { id, method, params },
sendError: io.sendError,
handleRequest: async (request) => handler(request.id, request.params),
});
}
The dispatch table is keyed by the protocol package's own method vocabulary (BRIDGE_REQUEST_METHODS), so it cannot drift from the schemas. A vocabulary method with no handler (thread/fork, thread/archive) answers -32601 like any unknown method; the runtime only sends capability-gated methods to bridges that advertised them.
The handshake
[BRIDGE_REQUEST_METHODS.initialize]: (id, params) => {
const parsed = initializeParamsSchema.safeParse(params);
if (!parsed.success) { invalidParams(id, "initialize", parsed.error.issues); return; }
io.sendResult(id, {
protocolVersion: PROVIDER_BRIDGE_PROTOCOL_VERSION, // 2
capabilities: {
grammarVersions: [THREAD_DELTA_GRAMMAR_V3, THREAD_DELTA_GRAMMAR_V3], // [3, 3]
sessionRestore: true, // a released session re-attaches from its id
threadArchive: false,
threadRename: false,
threadGoalClear: false,
fork: "none",
approvalEnforcedBy: "runtime",
steerMode: "queue", // a steer waits for the next prompt boundary
},
});
},
Session-behavior facts are reported here, never declared: the code that implements a feature is the code that says it exists. State the grammar range explicitly; a bridge that says nothing reads as [2, 2] and is refused, because the runtime's assembler speaks v3 only. The echo bridge does not handle skills/configure, so it leaves skills.configure at its default of false and the runtime never sends it.
Sessions: start, resume, stop
Every session construction is a provider id-space boundary. The bridge announces identity with a thread/identity notification, then emits a session.reset delta so the assembler drops any state it still holds for the thread, then answers the request with { providerThreadId, sessionRestorable }. The providerThreadId on the result is required: the runtime adopts no session without it, and the notification does not stand in for it.
function openSession({ threadId, providerThreadId, cwd, dynamicTools }): Session {
const session = { threadId, providerThreadId, cwd, turnsEchoed: 0,
usageTotal: ZERO_TOKEN_USAGE,
tools: new Map((dynamicTools ?? []).map((t) => [t.name, t])) };
sessions.set(threadId, session);
notify(BRIDGE_NOTIFICATION_METHODS.threadIdentity, { threadId, providerThreadId });
emitDeltas(threadId, [{ kind: "session.reset" }]);
return session;
}
[BRIDGE_REQUEST_METHODS.threadStart]: (id, params) => {
const parsed = threadStartParamsSchema.safeParse(params);
if (!parsed.success) { invalidParams(id, "thread/start", parsed.error.issues); return; }
threadCounter += 1;
const providerThreadId = `echo_${instanceNonce}_${threadCounter}`;
const session = openSession({ threadId: parsed.data.threadId, providerThreadId,
cwd: parsed.data.cwd, dynamicTools: parsed.data.dynamicTools });
io.sendResult(id, { providerThreadId, sessionRestorable: true }); // providerThreadId: required
// A start that carries input runs its first turn now. It has no clientRequestId
// (only turn/start and turn/steer carry one), so no input.accepted is emitted.
if (parsed.data.input !== undefined && parsed.data.input.length > 0) {
runEchoTurn({ session, input: parsed.data.input,
options: parsed.data.options.providerOptions });
}
},
[BRIDGE_REQUEST_METHODS.threadResume]: (id, params) => {
// Stateless resume: re-adopt the caller's providerThreadId. The session.reset
// inside openSession keeps assembler-minted ids unique across the resume.
...
io.sendResult(id, { providerThreadId: parsed.data.providerThreadId, sessionRestorable: true });
},
[BRIDGE_REQUEST_METHODS.threadStop]: (id, params) => {
// intent: "interrupt" settles an active turn; "release" detaches an idle
// session and must fabricate nothing. Either way the bridge holds nothing
// for the thread after it answers.
sessions.delete(parsed.data.threadId);
io.sendResult(id, {});
},
The turn: deltas with presentation
turn/start carries the user's input, a clientRequestId and the execution options. The bridge acknowledges the request and then streams the turn as batched thread/delta notifications: input.accepted → turn.open → items → usage → turn.boundary. Every item.open and item.close carries a presentation, and every item.close carries the full terminal item shape.
deltas.push({ kind: "input.accepted", clientRequestId });
deltas.push({ kind: "turn.open" });
// A shell command with streamed output.
const id = itemId(turn, "command");
const command = `echo ${JSON.stringify(turn.prompt)}`;
const presentation = commandPresentation(command);
deltas.push(
{ kind: "item.open", key: { providerItemId: id },
item: { type: "command", command, cwd: turn.session.cwd }, presentation },
{ kind: "item.outputDelta", key: { providerItemId: id }, channel: "command", text: output },
{ kind: "item.close", key: { providerItemId: id }, status: "completed", exitCode: 0,
aggregatedOutput: output,
item: { type: "command", command, cwd: turn.session.cwd,
aggregatedOutput: output, exitCode: 0, durationMs: 1 },
presentation },
);
// fileRead, search, a delegation with a keyed child turn, planSteps,
// a suppressed tool row ... (see the example for each)
// The extension item: payload is opaque on the wire; the server validates it.
deltas.push(
{ kind: "item.open", key: { providerItemId: receiptId },
item: { type: "extension", kind: ECHO_RECEIPT_KIND, payload: receipt },
presentation: receiptPresentation(receipt) },
{ kind: "item.close", key: { providerItemId: receiptId }, status: "completed",
item: { type: "extension", kind: ECHO_RECEIPT_KIND, payload: receipt },
presentation: receiptPresentation(receipt) },
);
// Thread state: the whole snapshot every time, latest wins.
deltas.push({ kind: "extension.state", extensionKind: ECHO_MOOD_KIND, payload: mood });
// The echoed message: one streaming dialect.
deltas.push(
{ kind: "item.open", key: messageKey, item: { type: "agentMessage", text: "" },
presentation: AGENT_MESSAGE_PRESENTATION },
{ kind: "item.textDelta", key: messageKey, channel: "agentMessage", text: firstLine },
{ kind: "item.textDelta", key: messageKey, channel: "agentMessage", text: rest },
{ kind: "item.textClose", key: messageKey, channel: "agentMessage", text },
);
// One usage dialect: this turn plus the running total, then the boundary.
session.usageTotal = addTokenUsage(session.usageTotal, last);
deltas.push(
{ kind: "usage", total: session.usageTotal, last, modelContextWindow: 8192 },
{ kind: "contextWindow", used: session.usageTotal.totalTokens, size: 8192,
estimated: true, attach: "open" },
{ kind: "turn.boundary", status: "completed" },
);
emitDeltas(session.threadId, deltas);
Two details worth copying. A zero-work prompt (/noop in the echo) still settles: it emits input.accepted and a turn.boundary with claimIfIdle: true, so the thread never hangs behind a turn that did nothing. And the delegation's child turn is keyed (turn.open { providerTurnId, parentRef }) and names the delegation row as its parentRef, which is how the assembler links child work to its row.
Calling a bb tool
The runtime injects bb's dynamic tools (plugin-registered tools, with their resolved presentation) as dynamicTools on thread/start, thread/resume and thread/fork. A bridge calls one by sending an item/tool/call request and finishing the turn when the reply arrives.
deltas.push({
kind: "item.open", key: { providerItemId: id },
item: { type: "tool", tool: ECHO_STAMP_TOOL_NAME, server: "bb", args: { text: prompt } },
presentation: stampTool.presentation, // the server resolved it for us
});
emitDeltas(session.threadId, deltas);
const requestId = sendRequest(BRIDGE_INBOUND_REQUEST_METHODS.toolCall, {
providerThreadId: session.providerThreadId,
threadId: session.threadId,
turnId: null, // the runtime resolves the open turn
callId: id, // our provider item id ...
tool: ECHO_STAMP_TOOL_NAME,
arguments: { text: prompt },
providerNativeIds: true, // ... which the runtime maps through the assembler
});
pendingToolCalls.set(requestId, { turn });
// later, in handleResponse:
const decoded = decodeToolCallResponsePayload(parsed.data.result);
finishEchoTurn(pending.turn, { content: decoded.content, isError: decoded.isError });
Maintenance
Because the declaration turned on maintenance.health, the server polls provider/health through the bridge. The echo answers a fixed ready:
[BRIDGE_REQUEST_METHODS.providerHealth]: (id, params) => {
const parsed = providerMaintenanceParamsSchema.safeParse(params); // { providerId, cwd?, providerOptions? }
if (!parsed.success) { invalidParams(id, "provider/health", parsed.error.issues); return; }
io.sendResult(id, {
supported: true,
health: { status: "ready", statusMessage: null, accountEmail: null, planLabel: null,
installedVersion: null, minimumSupportedVersion: null,
canInstall: false, canUpdate: false, loginCommand: null },
});
},
The rest of the bridge kit #
The echo bridge already writes through createBridgeIo and runs every handler through runBridgeRequest; it hand-writes its dispatch and its tool-call bookkeeping so the protocol is visible. The bridge kit in @get-bb/plugin-sdk/provider-bridge offers those pieces ready-made too:
import {
createBridgeIo, createBridgeLineHandler, runBridgeRequest,
experimental_BridgeRecoveryError, createPendingToolCallTracker,
} from "@get-bb/plugin-sdk/provider-bridge";
const io = createBridgeIo(); // { send, sendResult, sendError }
const tools = createPendingToolCallTracker({ sendToolCall: io.send });
export const handleLine = createBridgeLineHandler({
handleParsedMessage(message) {
// decode, then for a request:
runBridgeRequest({
request,
sendError: io.sendError,
async handleRequest(request) {
// ... throw new experimental_BridgeRecoveryError({ code, message,
// recovery: { kind: "authRequired", message, retryable: false } })
// and runBridgeRequest answers with error.data.recovery.
},
});
},
});
// Inside a turn, when the agent calls a bb tool:
const result = await tools.forwardToolCall({
scope: session, threadId, providerThreadId, toolName, arguments: args,
}); // never rejects: failures resolve as { isError: true }
runBridgeRequest turns a thrown experimental_BridgeRecoveryError into a JSON-RPC error carrying error.data.recovery, and any other throw into a -32000 error with the message. A bridge that fronts a user-installed CLI also gets the maintenance toolkit (experimental_resolveExecutablePath, experimental_readCliVersion, experimental_npmGlobalInstallCommand, …) and the bounded line reader for its child (experimental_readBoundedLines). The full kit list is in the reference.
6. Run it in bb #
bb plugin install ./examples/plugins/echo-provider --yes
bb plugin config echo-provider set shout true # optional: prove the settings round trip
- Open bb, start a new thread, and pick Echo in the provider picker. The picker lists providers in plugin install order (bundled first-party plugins first); the user reorders them and picks a default under Settings → Providers, or with
bb settings general providerOrder '["echo-agent","codex"]'andbb settings general defaultProviderId echo-agent. - Send a message. The timeline shows the scripted turn: a command with output, a file read, a search, a delegation with its child turn, a plan, a collapsed bookkeeping tool, the
echo_stampbb tool, the receipt row drawn with the plugin's own receipt icon, and the echoed message reportingproviderOptions (server): shout=true ...plus the passed-through env var. - Send
/noopto see a zero-work turn settle, or includemalformed-receiptto watch the server replace an invalid extension payload withprovider/unhandled. - After editing sources,
bb plugin reload echo-provider, or runbb plugin devfrom the plugin directory to rebuild and reload on every save.
What happens underneath: on install the server builds dist/host.js and records its digest. A thread command for echo-agent carries a bridgeLaunch spec ({ source: { kind: "artifact", pluginId, digest, byteLength } }) to the daemon, beside the derived providerOptions and the resolved dynamicTools. The daemon downloads the bytes, verifies the digest before caching, and runs the artifact with its own Node through the bridge bootstrap. It never executes unverified bytes, and a bridge runs only for an installed, enabled plugin.
7. Write the conformance test #
This is the test every provider bridge should ship. It drives the bridge in-process through the published scenarios and asserts a fully green report. The transport's send is your exported handleLine and takeMessages is the captured output's own drain; the kit runs each thread/delta batch through the real delta assembler itself, under the providerId you name, so the grammar checks see the canonical events the runtime would build and cross-resume id uniqueness is checked against the ids it would really mint.
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, expect, it } from "vitest";
import {
experimental_captureBridgeJsonRpcOutput as captureBridgeJsonRpcOutput,
experimental_formatConformanceReport as formatConformanceReport,
experimental_runBridgeConformance as runBridgeConformance,
} from "@get-bb/plugin-sdk/provider-bridge/testing";
import type { CapturedBridgeJsonRpcOutput } from "@get-bb/plugin-sdk/provider-bridge/testing";
import { handleLine } from "./src/provider-bridge.js";
import { ECHO_PLUGIN_ID } from "./src/vocabulary.js";
let output: CapturedBridgeJsonRpcOutput;
let workspaceDir: string;
beforeEach(() => { workspaceDir = mkdtempSync(join(tmpdir(), "bb-echo-conformance-"));
output = captureBridgeJsonRpcOutput(); });
afterEach(() => { output.restore(); rmSync(workspaceDir, { recursive: true, force: true }); });
it("passes the canonical protocol suite", async () => {
const report = await runBridgeConformance({
transport: { send: handleLine, takeMessages: output.takeMessages },
// The kit holds one stateful assembler for the whole run under this id.
providerId: "echo",
session: {
cwd: workspaceDir,
promptInput: [{ type: "text", text: "say hello", mentions: [] }],
zeroWorkPromptInput: [{ type: "text", text: "/noop", mentions: [] }],
// What package.json declares under bb.branding.experimental_icons: the
// receipt row's "echo-provider/receipt" glyph must name one of these.
icons: { pluginId: ECHO_PLUGIN_ID, names: ["receipt"] },
},
timeoutMs: 5_000,
});
output.restore();
console.info(`echo bridge conformance:\n${formatConformanceReport(report)}`);
expect(report.passed).toBe(true);
}, 30_000);
The echo test additionally pins the exact rule set it expects, fifteen rules all "pass" (the thirteen every bridge runs plus the zero-work turn and the declared-icon check): rpc/unknown-method, rpc/invalid-params, rpc/non-json-ignored, rpc/response-not-request, handshake/initialize, session/start-identity, turn/lifecycle, events/schema-valid, item/opens-before-delta, stop/release-not-interrupted, session/resume-identity, session/resume-id-uniqueness, skills/configure-declared, presentation/icon-namespaced-declared, turn/settles-without-activity. The echo declares fork: "none", so session/fork-identity produces no result for it. The icon rule is enabled by the icons fixture and fails on any item whose presentation.icon.glyph is a namespaced glyph that names another plugin or an undeclared name, which is what the server would refuse at ingest. The Testing page explains each rule, the stream test that asserts what every row becomes, and the recorded-replay test.
Next steps #
- Wrapping an existing agent that speaks ACP? You may not need to write a bridge at all: see the ACP kit.
- Driving a CLI or SDK underneath the bridge? Read process topology and lifecycle before you spawn anything.
- Rendering your extension kinds on the web with your own component:
app.slots.experimental_timelineRenderer.