bb · provider plugins

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 #

One rule

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" }
}

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:

icons/receipt.svg
<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:

src/vocabulary.ts (continued)
// 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.ts
import 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 as logoUrl and drawn as a currentColor mask (a monochrome mark follows the theme); a glyph name arrives as icon.glyph.
strings
signInHint, expiredHint, installUrl (required inside the object), plus optional brandPrefix (stripped from model display names), planModeCopy (banner copy for the plan action) and iconTint { light, dark }. Core surfaces render this copy instead of keying on your id.
maintenance
{ health?, usage?, installation? }, each defaulting to false. Declares which sessionless requests your bridge answers so the server can skip the rest. The echo bridge declares only health, and its bridge answers provider/usage with 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 coarse reasoningLevels ladder. The bridge reports the operative truth at initialize and 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? }. id is the wire value the bridge receives. Declared lists are the cold-cache fallback; model/list is precise per model.
models
fallback: cold-cache models shown only until the first model/list probe completes (exactly one isDefault). scope: "host" when the bridge answers model/list from 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 inherited BB_* 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 a provider/unhandled in 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 as options.providerOptions. Core never reads it. Secret settings are omitted from settings because 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 (user and project sides, relative to the host home and the workspace; each entry a path or { path, recursive?, ancestors?, namePrefix?, skipIfManifest? }; at most 32 per side; an absolute side is refused), and whether your host entry answers resolveNativeRoots for 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 own provider/health result is not not_installed, so it requires maintenance.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.

host.ts
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.acceptedturn.open → items → usageturn.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
  1. 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"]' and bb settings general defaultProviderId echo-agent.
  2. 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_stamp bb tool, the receipt row drawn with the plugin's own receipt icon, and the echoed message reporting providerOptions (server): shout=true ... plus the passed-through env var.
  3. Send /noop to see a zero-work turn settle, or include malformed-receipt to watch the server replace an invalid extension payload with provider/unhandled.
  4. After editing sources, bb plugin reload echo-provider, or run bb plugin dev from 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.

provider-bridge.conformance.test.ts
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 #