This document is the contract for BB's provider plugin surface — what "a
provider is a plugin" means. Every change that touches this surface keeps it
true, and a test (packages/plugin-sdk/src/__tests__/provider-plugin-doc.test.ts)
checks its code blocks against the real types. Members that still carry the
experimental_ prefix are named with it here; each has an entry in
api_to_audit.md saying why. How to build, test, and ship a
provider plugin is in the bb-plugin-authoring skill
(plugins/bb-guide/skills/bb-plugin-authoring/references/providers.md); the
wire grammar is provider-bridge-protocol.md.
A "provider" is a coding agent BB can run a thread on (Claude Code, Codex, Pi, ACP agents such as Cursor or Amp). The design goal is that everything a provider touches is owned by its plugin — translating the agent's native output into BB's data model, projecting that data onto the timeline, and how its tools are represented — with the smallest possible provider-agnostic core.
- Zero first-party privilege. First-party providers use only the public API. Every special case is a public primitive or is deleted.
- Each fact lives in one place. A capability is declared or reported, never both. Presentation comes from the bridge, never from core tables.
- Core understands a small semantic vocabulary. Everything else is an extension kind with mandatory declarative presentation.
- Every client renders everything without plugin code. Plugin renderers are a web upgrade; mobile renders the declarative base.
A provider's output flows through these layers. The plugin owns the first two; core owns the rest and never branches on a provider id.
host agent ─► bridge (plugin) ─► thread/delta (core vocabulary) ─►
delta assembler (core) ─► ThreadEvent (core) ─► persistence (core) ─►
timeline projection (core) ─► renderers (core + optional plugin web renderer)
A plugin registers one or more providers through bb.providers.register. One
plugin may own several providers (the ACP plugin owns Cursor and the
user-configured agents); user-configured instances are rows in the plugin's
own settings that produce registrations at runtime.
bb.providers.register({
id: "claude-code", // flat; first registration wins; no reservation
displayName: "Claude Code",
family: undefined, // optional grouping key (the ACP agents share one)
icon: "./icons/claude.svg", // a plugin SVG, served as logoUrl; a glyph name; or "<pluginId>/<name>"
strings: {
signInHint: "Run `claude` on the machine to sign in.",
expiredHint: "Your Claude session expired. Run `claude`, then reload.",
installUrl: "https://docs.anthropic.com/claude-code",
brandPrefix: "Claude ", // optional; stripped from model display names
planModeCopy: undefined, // optional; plan-mode banner copy
iconTint: undefined, // optional { light, dark }
},
maintenance: { health: true, usage: true, installation: true }, // each defaults to false
capabilities: {
// pre-session facts, one client shape: ProviderInfo
permissionModes: ["accept-edits", "auto", "full"], // closed core enum
fork: "checkpoint", // "none" | "tip" | "checkpoint"
supportsNativeUserQuestion: true,
supportsManualCompaction: true,
supportsThreadArchive: true,
supportsThreadRename: true,
supportsServiceTier: false,
reasoningLevels: ["low", "high"], // the coarse ladder; `reasoningLevels` below is precise
},
reasoningLevels: [
// picker options; model/list is precise
{ id: "low", label: "Low" },
{ id: "high", label: "High" },
],
serviceTiers: undefined, // optional; open list of { id, label, description?, experimental_icon? },
// "default" is the standard tier; model/list is precise
composerActions: ["plan"], // "plan" | "goal"
completedTurnDisplay: "flat", // "collapse" (default) | "flat"; the user's per-provider setting wins
extensionKinds: {}, // "<name>": { item?: Schema, state?: Schema }
models: { fallback: [], scope: "host" }, // cold-cache placeholder; scope is
// "host" | "workspace" (default): how far one
// model/list answer travels
env: { passthrough: ["BB_CLAUDE_CODE_EXECUTABLE"] },
deriveProviderOptions(ctx) {
// called on every command
// ctx: { threadId, projectId, model, permissionMode, promptMode?, settings }
return {}; // opaque JSON handed to this plugin's bridge
},
});
// => { dispose(): void }Each model/list entry may carry supportedServiceTiers: [{ id, label?, description? }], the tiers that model accepts besides default. bb offers
only the ids the declaration also lists, preferring the entry's label and
description; an empty array hides the tier picker for that model, and an entry
without the field accepts every declared tier. The server rejects an explicit
tier the declaration does not list and passes the chosen id to the bridge as
serviceTier.
supportedReasoningEfforts[].reasoningEffort is an open, non-empty id: the
standard ladder (none, low, medium, high, xhigh, ultracode,
max, ultra) keeps its order and labels, and any other id is the
provider's own, shown under the entry's optional label and sent back as
reasoningLevel unchanged. A bridge that receives an id it does not know
omits it rather than failing the turn.
A model/list entry may also carry sessionOptions, the
bb/session-options option shape with value as the provider's default. A
select lists only the values that model supports; a boolean with
fixed: true says the model runs only with that value. The new-thread model
picker shows the union, disables models the current choices rule out, and
sends the choices as sessionOptions on the thread's first commands. The
server applies the same rule on every create: a caller that leaves the model
to bb starts on a model that fits (the default when it does), and a named
model the choices rule out is refused with a model that fits.
models.fallback is shown only until the first model/list probe completes,
or when a probe fails transiently; a non-empty list has exactly one
isDefault. bb keeps each machine's last successful model/list answer per
models.scope across daemon reconnects and server restarts, serves it
immediately, and refreshes it in the background once it is 10 minutes old.
"host" scope means the bridge answers from account or agent state and
ignores the workspace path, so bb probes once per machine. A stored answer is
discarded when the bridge fingerprint changes (plugin bundle digest, bridge
options, env passthrough, contributed provider environment). A list that depends on login state, CLI version, or
environment values is corrected only by the next refresh.
env.passthrough names the daemon environment variables the bridge may read:
provider processes are spawned with inherited BB_* variables stripped, and
exactly these are forwarded. deriveProviderOptions runs synchronously on
every session and turn command; its plain JSON result (at most 64 KiB) reaches
the bridge as options.providerOptions, merged over
experimental_bridgeOptions (a derived key replaces its static key), and core
never reads it. ctx.settings holds the plugin's own non-secret
bb.settings values.
A workspace-scoped provider's model/list carries cwd: the environment's
directory for an existing thread or a reused environment, and the project's
checkout on the chosen machine for a new thread in a project. It is absent when
neither is known (a thread without a project, or a project with no checkout on
that machine). A host-scoped provider never receives it.
Every model/list carries envVars: the provider environment plugins
contribute for that machine through bb.providers.experimental_contributeEnv,
resolved with a null thread and project and expanded like a session's
options.envVars. A bridge whose catalog depends on credentials or routing,
such as Codex behind the Account Pooler, launches its listing process with
these values so the picker matches what new threads can use.
Still experimental on the declaration (see api_to_audit.md):
experimental_bridgeOptions— a plain JSON object of at most 64 KiB, validated and frozen at registration and carried on every bridge request. Use it for immutable launch facts shared by all hosts, not user settings or machine-local state. It participates in bridge process identity, so changing it starts a new bridge process for the next runtime.experimental_visibility—"always"(default) or"installed", which lists the provider on a host only when its bridge'sprovider/healthstatus is notnot_installed. Such a declaration must support health; bridge failures hide only that provider.experimental_nativeSkillRootsandexperimental_nativeCommandRoots— where the agent keeps its own skills and slash commands, at most 32 roots per side. Each root is a path or{ path, recursive?, ancestors?, namePrefix?, skipIfManifest? }:recursivescans nested skill directories,ancestors(project roots only) also scans the same relative directory in every ancestor of the workspace up to the repository root,namePrefixis prepended to every name under the root, andskipIfManifestnames the marker file whose presence makes bb skip a directory as a vendor plugin rather than a skill. A symlink out of a project root is followed within the workspace for a plain root and within the repository root for a root that walks ancestors or that the plugin resolved. Declared roots are relative to the host home (user) or the workspace (project) only.experimental_resolvesNativeRoots— the plugin'sbb.hostentry answersresolveNativeRoots({ providerId, cwd })with the roots only that host and workspace know: a moved config directory, installed vendor plugins, config-file entries. A host-absolute directory is always the resolver's answer. An answer lists each path once per side, and the@get-bb/plugin-sdk/hostvendor-plugin readers keep the first root per path in answer order. bb scans each absolute path once per provider across the declared and resolved roots: the first root in declaration order wins — declared skills (project, then user), declared commands, then the resolved skills and commands, each in the order given — and a later root with the same path is dropped, so a resolved root that repeats a declared one is listed under the declared root's identity.
Rules:
- Ids are flat and collision-rejected: the first live registration of an id wins, a later one from another plugin fails that plugin's load, and no id is reserved ahead of time. Registrations replace wholesale on reload. Disabling the plugin removes the provider; open threads show a provider-unavailable state instead of erroring.
- Capabilities project to exactly one client shape,
ProviderInfo.ProviderInfo.maintenanceis the one shape of the three maintenance facts. - The plugin learns per-instance truth itself (probe through its own host RPC, register conservatively while the host is offline, re-register on connect).
- Picker order and the default provider are user settings (Settings →
Providers,
bb settings general providerOrder '["my-agent","codex"]',bb settings general defaultProviderId my-agent); the initial order is plugin install order, bundled first-party plugins first. completedTurnDisplayis the provider's default for finished turns in the thread timeline."collapse"folds a finished turn's work into one "Worked for" row beside the final answer;"flat"keeps every row visible, as while the turn ran — pick it when the agent narrates its work in text the user should keep reading. The user overrides it per provider in Settings → Providers or withbb settings completed-turns <provider> <mode>, and the server applies the result to the timeline, turn details, conversation outline, andbb thread log.- Third-party ACP agents (for example Amp) register the same way, with a bridge built from the published ACP kit.
export const experimental_providerBridge = experimental_defineProviderBridge({
handleLine,
start,
onClose,
});The export name and experimental_defineProviderBridge are the artifact
contract the daemon's bootstrap reads from every installed plugin. The
bootstrap owns the process boundary — argv, the plugin-scoped
dataDir/tempDir, bounded stdin framing, and signals — so importing the
module must start nothing.
One process per provider artifact; the bridge supervises any child processes. The runtime never scopes processes per thread and never matches error text.
Handshake (reported per session at initialize, never declared; field
semantics in provider-bridge-protocol.md,
"Versioning and capabilities"):
{
grammarVersions: [min, max], // the delta-grammar range this bridge speaks
sessionRestore: boolean,
threadArchive: boolean,
threadRename: boolean,
threadGoalClear: boolean,
fork: "none" | "tip" | "checkpoint",
approvalEnforcedBy: "runtime" | "provider",
steerMode: "inject" | "queue", // declared and recorded; nothing acts on it yet
skills: { configure: boolean }, // the bridge handles skills/configure
}Runtime → bridge: initialize, model/list,
thread/{start,resume,fork,stop,discard,archive,unarchive,name/set,goal/clear},
turn/{start,steer}, skills/configure {roots} (only when the handshake
declares skills.configure),
provider/{health,usage,installation/status,installation/run}.
Execution options ride every command and carry no provider-named field:
{ model, serviceTier?, reasoningLevel, promptMode?, sessionOptions?,
instructions, providerOptions: JsonValue } & PermissionPolicysessionOptions carries the user's pending choices among the options the
bridge published as bb/session-options
(provider-bridge-protocol.md, Grammar v3).
Bridge → runtime: notifications thread/delta (one streaming dialect, one
usage dialect), thread/identity, session/replaced, provider/recovery,
provider/raw, and error, plus the request channels item/tool/call and
interaction/request.
Recovery is typed, never text-matched:
// provider/recovery
{ kind: "sessionArchived" | "authRequired" | "restartRecommended"
| "staleTurn" | "rateLimited",
message: string, retryable: boolean }See provider-bridge-protocol.md, "Recovery hints", for the per-kind runtime actions and the carrier rule.
The delta assembler runs in the daemon and is generic for extension kinds. It
ships with the conformance kit and JSON-RPC harness as
@get-bb/plugin-sdk/provider-bridge/testing; the ACP bridge ships as
@get-bb/plugin-sdk/provider-bridge/acp.
Core item kinds — the kinds core acts on:
message · reasoning · plan · command · fileChange · fileRead · search
webSearch · webFetch · imageView · imageGeneration · backgroundTask
delegation · planSteps · compaction · tool
Extension item kinds — "<pluginId>/<name>", declared in
extensionKinds with an item schema, a state schema, or both, and validated
at server ingest; a payload that fails validation persists as a
provider/unhandled. Only the declaring plugin's own providers may emit a
kind: a thread whose provider another plugin registered gets a
provider/unhandled in its place, as it would for a foreign presentation
glyph.
Thread state — core: usage, contextWindow, rateLimits,
modelFallback, contextCleared. Extension: "<pluginId>/<name>", latest
snapshot wins per kind, same schema and emitter rules as extension items.
Two state kinds are bb's own and open to every provider: bb/provider-commands
(the provider's live slash commands for the thread) and bb/session-options
(its per-session settings other than the model and reasoning level). A user
picks a value in the model picker (the mode option sits in the composer
footer) or with
bb thread options --set; the server holds the choice, sends it as
sessionOptions with the next turn, and drops it once the bridge publishes
that value.
Delegation — one kind for every sub-agent encoding:
{ childRef: string, label: string, status: ItemStatus,
background: boolean, summary?: string } // child turns link by parentRefPresentation — attached by the bridge at item.open, persisted with the
item so it renders after the plugin is uninstalled or upgraded:
presentation: {
label: { pending: string, completed: string },
icon: { glyph: string }, // "FileText" or "<pluginId>/<name>"
title?: string, // row headline
detail?: string, // short markdown summary, length-capped
suppress?: boolean, // low-value rows (TodoWrite, ToolSearch)
tint?: { light: string, dark: string },
}icon.glyph is a name, never bytes or a path: a host glyph ("FileText")
or one of the plugin's own declared icons by its namespaced glyph
("<pluginId>/<name>", an entry of the manifest's
bb.branding.experimental_icons). The server rejects at ingest a namespaced
glyph that is not the emitting plugin's declared icon (provider/unhandled,
reason naming the glyph); for a server: "bb" tool row the emitting plugin is
the one that registered the tool, whose presentation the bridge stamps as
handed to it. Clients resolve the name against the plugin inventory they hold
and draw the SVG tinted with currentColor. If the plugin is gone or the name
unknown when the row renders, the per-kind fallback glyph draws — rows are
never rewritten when a plugin changes its map.
tint is a plain CSS colour per theme, and the forms every client paints are
hex, rgb()/rgba()/hsl()/hsla()/hwb() with a numeric alpha, and named
colours. The web also paints oklch(), lab(), lch(), color() and a
percentage alpha through CSS; React Native's colour parser does not, so on
mobile such a tint falls back to the neutral row colour (never to black).
Genericity rule: model fallback, context cleared, compaction skipped, and
background work stay core. Provider-specific concepts (Codex goals, the Codex
macos permission profile) are that plugin's extension kinds. Core keeps no
table of tool names.
Approvals (closed, policy-bearing — permission modes auto-decide these):
command · file_change · tool_use { tool, presentation } · permission_grant
accept-edits approves file_change; auto approves command +
file_change + tool_use; full approves all.
A tool_use approval is any tool call with no core kind (an MCP tool, a
provider-native tool). Its presentation — the same label, glyph, tint,
headline, and detail its timeline row carries — is the whole description
of the ask: the app, mobile, CLI, and the child-thread blocker summary render
it from presentation alone, never from a tool-name table. detail is
agent-authored Markdown on every surface it reaches (the row body, the
approval banner, on the web and on mobile): an image in it renders as its alt
text, never as a fetch the user did not decide on.
Requests (open): userQuestion and planReview render with core
renderers; "<pluginId>/<kind>" renders with the plugin through the existing
pendingInteraction slot. Any bridge may raise any kind. The server
fabricates no placeholder items.
Every status change of every interaction — any approval subject, a user
question, a plugin request — appends one system/interaction/lifecycle
event carrying the interaction's lifecycle record: id, status, origin, the
ask, and the answer, with the payload and the resolution paired by kind so the
event cannot hold an approval subject beside a user answer. The record keeps
what a reader needs to understand the ask and never the live ask's options
(availableDecisions) or a plugin form's data. The projection decides what
shows: a permission grant and a user question get a row; a command or
file-change approval shows on the provider's item, a plan review on the plan
tool call, a tool use on the tool call, a plugin request on the plugin's
form.
A bridge that keeps the payload it raised parses the wire resolution together
with it (providerInteractionOutcomeSchema), so its response encoder narrows
on the payload kind and a mismatched pair is a wire error, never a throw
inside the encoder.
Raising a plugin-defined request. A bridge sends interaction/request
with payload: { kind: "<pluginId>/<name>", title, data }, where name is
the id of the plugin's pendingInteraction slot registration and data is
whatever that form reads (the kind grammar is lowercase [a-z0-9-], so a
form a bridge can address must register a lowercase id). No permission mode
answers a request: it reaches the user through the plugin's form on the web
app (bb thread interactions respond <id> --value '<json>' from the CLI;
the phone shows a card that points at the desktop app), and the answer comes
back as { kind: "request_answer", value } — the form's submitted value,
capped at 64 KiB (PLUGIN_INTERACTION_MAX_PAYLOAD_BYTES, the same cap on the
request's data). The server accepts a request only while the plugin named
by the prefix is loaded; a request for an unknown plugin is refused with an
error the bridge sees. A provider's request has no cancel; backing out stops
the turn, as with a provider's question. value is untrusted input to the
bridge: no form schema exists for the server to validate it against, so a
bridge parses it as it would any client-supplied JSON.
Server-side projection folds every item, including extension items, into one row shape:
TimelineRow { kind: string, payload, presentation }payload is a sketch, not a landed field: rows stay typed per kind and only
extension rows carry a payload today (provider-plugin-doc.test.ts keeps
the gap entry).
No tool-name tables, no arg-field guessing, no tool_name virtual column. A
plugin renders its own extension kinds and the generic tool items its
provider emitted:
app.slots.experimental_timelineRenderer({ kind, component });
// component props: { row, payload, presentation, thread, Original }Core kinds always use core renderers, customized through presentation only.
A provider plugin's frontend bundle loads like every other plugin's: in the
deferred boot pass after the first route paints, not on first paint and not
only when one of its providers is in use. Everything its app.tsx registers —
a settings section, nav panel, palette action, provider icon,
pending-interaction form, or app.composer.customize chrome — is present from
that boot pass on, including on the New Thread page; until then the served
logo stands in for a registered provider icon. Keep the bundle small: it ships
to every window whether or not the provider is selected. Mobile renders the
declarative base for every kind.
The provider directory is available to plugins through
app.experimental_useProviders() (frontend) and bb.sdk.providers
(backend); no plugin re-vendors provider names or icons. Every provider's
mark is the icon its plugin declared — a path or declared icon served as
logoUrl and drawn as a currentColor mask, or a host glyph name; core
vendors no brand marks.
Bridges are delivered as content-addressed plugin artifacts. On install or
reload the server builds dist/host.js and records its digest; thread
commands carry {pluginId, digest}, and the host daemon downloads the
bytes, verifies the digest before caching them, and runs the artifact with
its own node. It is the same artifact and cache as the plugin's host RPC
entry. There is no daemon-bundled provider path: a first-party provider ships
the same way a marketplace provider does. Trust is installation trust,
identical to every other plugin: a bridge runs only for an installed, enabled
plugin, and only on hosts its server instructs.
Helper tasks (thread titles, commit messages, voice transcripts) are
plugin-served through bb.experimental_aiServices, documented in the
bb-plugin-authoring skill's backend-cli-agents.md.