Provider plugin API
August 24, 2026 · View on GitHub
This document is the reference for BB's provider plugin surface — what "a
provider is a plugin" means. It has no phases: 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.
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.
Principles
- 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.
Layers
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)
1. Registration (plugin server code)
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, model/list is precise
composerActions: ["plan"], // "plan" | "goal"
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 }
Still experimental on the declaration (see api_to_audit.md):
experimental_visibility ("installed" hides the row until the bridge's
health probe finds the agent), experimental_bridgeOptions (immutable JSON
forwarded opaquely to the bridge), experimental_nativeSkillRoots and
experimental_nativeCommandRoots (where the agent keeps its own skills and
slash commands; each root is a path or { path, recursive?, ancestors?, namePrefix?, skipIfManifest? }, where recursive scans nested skill
directories, ancestors (project roots only) also scans the same relative
directory in every ancestor of the workspace up to the repository root,
namePrefix is prepended to every name under the root, and skipIfManifest
names 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) and
experimental_resolvesNativeRoots (the plugin's bb.host
entry answers resolveNativeRoots({ providerId, cwd }) with the roots only
that host and workspace know: a moved config directory, installed vendor
plugins, config-file entries; an answer lists each path once per side, and
the @get-bb/plugin-sdk/host vendor-plugin readers keep the first root per
path in answer order). Declared roots are relative to the host home
(user) or the workspace (project) only; a host-absolute directory is
always the resolver's answer. 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:
- Capabilities project to exactly one client shape,
ProviderInfo.ProviderInfo.maintenanceis the one shape of the three maintenance facts; clients ship with the server, so nothing is served beside it. - 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; the initial default is plugin install order. First-party plugins install first at bootstrap.
- Third-party ACP agents (for example Amp) register the same way, with a bridge built from the published ACP kit.
2. Bridge (plugin bb.host artifact, runs on the host)
export const experimental_providerBridge = experimental_defineProviderBridge({
handleLine, start, onClose,
})
The export name and experimental_defineProviderBridge / experimental_apiVersion
are the artifact contract the daemon bootstrap reads from every installed
plugin; they keep their prefix until the bridge-kit audit settles the
deprecation window between independently-updating artifacts and daemons.
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):
{
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
}
steerMode names how the bridge delivers turn/steer while a turn is live
(inject feeds it into the running model loop, queue holds it for the next
prompt boundary), but nothing in the runtime, server or clients reads it yet:
the runtime sends turn/steer either way, and a steer whose turn is gone is
dropped on the bridge's staleTurn recovery hint whatever the mode.
Runtime → bridge: 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?, instructions,
providerOptions: JsonValue } & PermissionPolicy
Bridge → runtime: thread/delta (one streaming dialect, one usage
dialect), provider/recovery, session/replaced, 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 (a rejected
request carries the hint as error.data.recovery; an unsolicited one rides
the provider/recovery notification; never both for one event).
The delta assembler stays in the daemon, is generic for extension kinds, and
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; the first-party ACP plugin consumes
the same kit.
3. Vocabulary
Core item kinds — the kinds core acts on:
message · reasoning · command · fileChange · fileRead · search · webSearch
webFetch · imageView · delegation · planSteps · compaction · tool
Extension item kinds — "<pluginId>/<name>", plugin-declared schema,
validated at server ingest. 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.
Delegation — one kind replaces three encodings and thread/openWork:
{ childRef: string, label: string, status: ItemStatus,
background: boolean, summary?: string } // child turns link by parentRef
Presentation — 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 map of name → plugin-relative SVG). 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 icon is simply not found and 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. Codex goals and the Codex macos permission
profile are codex extension kinds, with read-time conversion of persisted
rows.
Core keeps no table of tool names. The one exception is a quarantined
legacy-data adapter in packages/domain/src/legacy-thread-events.ts: a
toolCall row persisted before bridges stamped presentation upgrades at
read time to the item the bridge emits today (fileRead / search, a
suppressed bookkeeping call, a trimmed agent result). It is keyed on the
absence of presentation, never on a provider id, and is deleted by the
one-time backfill migration it names.
4. Interactions
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 (describePendingInteractionToolUse in
@bb/core-ui), 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. One
interaction-lifecycle event type; the server fabricates no placeholder items.
The one event is system/interaction/lifecycle. Every status change of every
interaction — any approval subject, a user question, a plugin request —
appends one, carrying the interaction's lifecycle record (@bb/domain
interactionLifecycleSchema): 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. Rows persisted under the earlier per-shape events
(system/permissionGrant/lifecycle, system/userQuestion/lifecycle) decode
into this event at read time (convertLegacyStoredThreadEvent). The
conversion is one-way: a server rolled back to a build before this event
fails to decode system/interaction/lifecycle rows, so the interactions
recorded since the upgrade lose their timeline rows until the server moves
forward again.
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.
5. Projection and rendering
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.
Provider frontend bundles load lazily on the first thread of that provider and
never enter the boot payload. Everything the bundle registers arrives with it:
a settings section, nav panel, palette action, provider icon or
pending-interaction form registered from a provider plugin's app.tsx appears
on the first thread of one of its providers, when one of its forms is asked
for, or when its own panel route is opened — not at boot. Boot-time UI belongs
in a separate, non-provider plugin. 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 logo its plugin declared, served as logoUrl and drawn as a
currentColor mask; core vendors no brand marks. A plugin's declared icons
(bb.branding.experimental_icons) reach clients the same way: the plugins
inventory carries each plugin's icons (name → hashed SVG URL), and both
the web timeline and mobile resolve a row's "<pluginId>/<name>" glyph
against it before drawing, falling back to the per-kind glyph when the name
is not found.
6. Distribution
Bridges are delivered as content-addressed plugin artifacts the daemon caches by verified hash. 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.
7. AI services
bb's helper inference (thread titles, commit messages) and voice transcription
are plugin-served too. A plugin registers
bb.experimental_aiServices.register({ id, displayName, kinds }) and
implements experimental_aiServicesHostContract
(@get-bb/plugin-sdk/ai-services: ai.inference.complete,
ai.voice.transcribe, each carrying serviceId) in its bb.host entry. The
user chooses with BB_INFERENCE / BB_TRANSCRIPTION = <serviceId>/<model>;
core calls the registered plugin on the primary host and applies its own
retry/fallback policy to the { ok: false, code } results. The codex plugin
serves codex from the codex CLI's own credentials; there is no daemon-bundled
client. Ids the server serves itself (openai transcription, the builtin
inference providers) are reserved: they route server-direct before the
registry and register refuses them; a cross-plugin id collision fails the
later plugin's load at the register call. See docs/api_to_audit.md for the
audit items.