Provider plugin API
Target state (42 decisions, 2026-08-20), the morph plan, and the verification and guardrail program. Nothing here describes today's code.
Principles
- Zero first-party privilege. First-party providers use only the public API. Every special case becomes 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 an upgrade on web; mobile renders the declarative base.
1. Registration plugin server code
bb.providers.register({
id: string, // flat; first registration wins; no reservation table
displayName: string,
family?: string, // grouping only; replaces the acp- prefix
icon: { glyph: string } | { asset: string },
strings: {
signInHint: string, expiredHint: string, installUrl: string,
brandPrefix?: string, planModeCopy?: string,
iconTint?: { light: string, dark: string },
},
permissionModes: ("accept-edits" | "auto" | "full")[], // closed core enum (policy)
reasoningLevels: { id, label, description? }[], // fallback; model/list is precise
serviceTiers?: { id, label, description? }[], // fallback; model/list is precise
fork: "none" | "tip" | "checkpoint", // exact per instance
supportsNativeUserQuestion: boolean,
supportsManualCompaction: boolean,
maintenance: { health: boolean, usage: boolean, installation: boolean },
composerActions: ({ kind: "plan" } | { name, trigger, description })[],
extensionKinds: { [name: string]: { item?: Schema, state?: Schema } },
models: { fallback?: Model[] }, // cold-cache placeholder only
env: { passthrough: string[] }, // daemon env the bridge may read
deriveProviderOptions(ctx: {
threadId, projectId, model, permissionMode, promptMode?, settings,
}): JsonValue, // called on every command
}) => { dispose(): void }
- One plugin registers N providers. User-configured instances are rows in the plugin's own settings that produce registrations at runtime.
- The plugin learns per-instance truth itself: probe through its host RPC, register conservatively while the host is offline, re-register on connect.
- Picker order and default provider are user settings. The initial default is plugin install order.
- Capabilities project to exactly one client shape,
ProviderInfo. - Third-party ACP agents (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 providerBridge = defineProviderBridge({ handleLine, start?, onClose? })
// Handshake — reported per session, never declared
initialize → {
grammarVersions: [min, max],
sessionRestore, threadArchive, threadRename: boolean,
approvalEnforcedBy: "runtime" | "provider",
steerMode: "inject" | "queue",
}
// Runtime → bridge
model/list · thread/{start,resume,fork,stop,discard,archive,unarchive,name/set}
turn/{start,steer} · skills/configure {roots: Root[]} · skills/scanRoots {cwd}
provider/{health,usage,installation/status,installation/run}
// Execution options on every command — no provider-named field
{ model, serviceTier, reasoningLevel, promptMode?: "plan", instructions,
providerOptions: JsonValue } & PermissionPolicy
// Tool definitions handed to the bridge carry `presentation`
// Bridge → runtime
thread/delta {deltas} // one streaming dialect, one usage dialect
provider/recovery { kind: "sessionArchived" | "authRequired" | "restartRecommended"
| "staleTurn" | "rateLimited", message, retryable }
session/replaced
item/tool/call · interaction/request
- One process per provider artifact. The bridge supervises any children. The runtime never scopes processes per thread and never matches error text.
- The 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 thread/delta v3 → domain events
// Core item kinds — the kinds core acts on
message · reasoning · command · fileChange · fileRead · search · webSearch · webFetch
imageView · delegation · planSteps · compaction · tool
// Extension item kinds — plugin-declared schema, validated at server ingest
"<pluginId>/<name>" { payload: JsonValue }
// Thread state (core): usage · contextWindow · rateLimits · modelFallback · contextCleared
// Extension state: "<pluginId>/<name>", latest snapshot wins per kind
// Delegation — one kind, replaces three encodings and thread/openWork
delegation { childRef, label, status, background: boolean, summary? } // child turns link by parentRef
// Presentation — on EVERY item, attached by the bridge at item.open, persisted with the event
presentation: {
label: { pending: string, completed: string },
icon: { glyph: string } | { asset: string },
title?: string, // row headline
detail?: string, // short markdown summary, capped
suppress?: boolean, // low-value rows (TodoWrite, ToolSearch)
tint?: { light: string, dark: string },
}
Genericity rule. Model fallback, context cleared, compaction skipped, and background work stay core. Codex goals and the Codex macos permission profile move to codex extension kinds with read-time conversion of persisted rows.
4. Interactions
Approvals closed, policy-bearing
command · fileChange
toolUse { tool, presentation }
permissionGrant
accept-edits approves fileChange; auto approves command + fileChange + toolUse; full approves all.
Requests open
userQuestion // core renderer
planReview // core renderer
"<pluginId>/<kind>" // plugin renderer via pendingInteraction slot
Any bridge may raise any kind. One lifecycle event type. The server fabricates no commandExecution{cwd:""} items.
5. Projection and rendering
// Server-side thread-view folds every item into one row shape. No tool-name tables.
TimelineRow { kind: string, payload: JsonValue, presentation: Presentation, ... }
// Plugin web renderer — own extension kinds and own generic `tool` items only
app.slots.timelineRenderer({ kind, component })
component props: { row, payload, presentation, thread, Original }
// Open requests
app.slots.pendingInteraction({ kind, component }) // exists today for plugin interactions
// Directory
app.useProviders() · bb.sdk.providers.list()
- Core kinds always use core renderers, customized through
presentationonly. - Provider frontend bundles load lazily on the first thread of that provider, through a manifest flag any plugin can use. They never enter the boot payload.
- Mobile renders the declarative base for every kind, including the extension fallback.
6. Settings and product surfaces
- Core renders a generic provider entry: name, icon, health, usage, install, order, default. The plugin's settings-section slot owns its rich pane.
- Declared
stringsfeed usage banners, pickers, mobile, and the agent guide. - Plan mode is a bb prompt mode for any provider that declares the
plancomposer action; each bridge maps it natively. model/listis the only live model source;models.fallbackis the only placeholder;customModelsis a setting of the owning plugin.- Host AI services (voice, inference) are a generic plugin service registered through host RPC; core routes to the user's chosen service.
bb provider listprints capabilities;bb thread spawn --reasoningvalidates against the provider's list.
7. Deleted from core
| Area | Goes away |
|---|---|
| Pi | packages/agent-runtime/src/pi/**, the daemon-bundled source kind, DAEMON_BUNDLED_PROVIDER_BRIDGE_IDS, pi SDK deps in runtime and daemon |
| Codex runtime | three error regexes, account-restart set, per-thread process keys, rename retry, archive idempotency string match, the daemon per-thread lane mirror |
| ACP tier | acp-provider-tier.ts, customAcpAgents core config, typed acpLaunchSpec on 8 daemon commands, 3× isAcpProviderId, the ACP logo route |
| Shared contracts | claudeCodePermissionMode, workflowsEnabled, memoryEnabled, providerSubagentsEnabled; the five claude/codex settings keys; supportsWorkflows, experimental_visibility, goalClear |
| Projection | claude-task-tools.ts, thread-view tool-name tables, tool-call-suppression.ts, todo extraction by tool name, statusLabels, the tool_name virtual column |
| Catalogs | three Claude model tables, PLACEHOLDER_PROVIDER_INFOS, RESERVED_PROVIDER_ID_OWNERS, PRODUCT_PROVIDER_ORDER |
| Daemon | codex.inference.complete, codex.voice.transcribe, codex-auth.ts, the ChatGPT client, BB_CLAUDE_CODE_EXECUTABLE passthrough |
| Tests | the ProviderAdapter interface and the 674-line fake adapter; the echo bridge becomes the harness default |
| Naming | every experimental_ prefix on this surface, once, at the end |
Workstreams
One gh stack per workstream, landed one layer at a time on main. No toggles. Each workstream is a bb thread in its own worktree; this thread coordinates.
Step 0 — coordinator
BB_PROVIDER_CORPUS_DIR), row snapshots of the 307 threads, timeline-build and event-size baselines, permission matrix pinned (A4, A5, G12)docs/provider-plugin-api.md, no phasesTimelineRow, declaration type, ProviderInfo, interaction split, mobile kind-map test. Types only.Step 1 — parallel
bb.providers.register, strings, options hook, promptMode, flat ids, single ProviderInfo, useProviders()pi --mode rpc through the conformance kit; report gapsStep 2 — after WS1a layer 1
steerMode, skills methods, pi rewrite, AI servicesplanReviewStep 3 — coordinator
experimental_, clear api_to_audit.mdRegression confidence — what makes "no regression" checkable
The plan abandons byte-equivalence with the old translators on purpose, which removes the regression oracle the goldens provide today. The calibration sessions are scripted fakes we wrote, the integration suite runs a fake adapter, and mobile shipped a key-mismatch regression unnoticed. These five additions and two sequencing rules replace the lost oracle with machine checks on real data.
| Addition | What it is | What it catches |
|---|---|---|
| A1 Additive-then-delete inside every stack | Layer 1 adds v3 (new kinds and presentation accepted, optional; v2 dialects still accepted). Middle layers migrate bridges one at a time. The top layer deletes the v2 paths and makes presentation required. "Break once" still holds for the outside world. | A half-migrated provider on main. Every commit has every provider working. |
| A2 Dual-path parity replay | Resurrect calibration-diff.ts (normalizeCalibrationEvents interns ids and blanks path-dependent fields). Every session runs through the old bridge (a worktree at the pre-migration commit) and the new bridge. Diff assembled events, then diff projected rows, against an explicit allowlist of intended differences. | Any unlisted difference in what the user sees. A machine oracle, not a human reading golden diffs. |
| A3 Real provider recordings | A bridge record mode (BB_PROVIDER_BRIDGE_RECORD_DIR) tees raw provider lines and runtime requests. Run the live-QA matrix once per provider with it on: turn, steer, approve and deny, question, subagent, resume, fork, 401, 429, archived session, empty rollout. Redact, commit as fixtures. Conformance, goldens, and parity replay run on recordings, not on scripts we wrote. First PR of WS1a; no bridge migrates before it lands. | The bridge matching our model of the provider instead of the provider. |
| A4 Production-thread row snapshots | The extracted corpus (below): 307 real threads. Snapshot their projected rows with today's pipeline. Re-project after every layer. Zero unlisted diffs. | Read-time conversion and persisted-data compatibility, on real data instead of synthetic. |
| A5 Permission matrix pinned first | One test enumerates every (permission mode × approval subject × approvalEnforcedBy) cell and its outcome before the union changes. WS5 keeps every existing cell identical and adds cells only for toolUse. | A wider approval union widening auto-approval. |
Sequencing rules. (1) Pi stays on its in-process path until the spike proves RPC parity on recordings. (2) The mobile kind-map exhaustiveness test lands in the contract PR, so WS3 cannot add a kind that mobile does not render.
Confidence by area
| Area | Now | With A1–A5 |
|---|---|---|
| Bridge translation | High | High |
| Registry, ids, strings | High | High |
| Assembler, grammar | Medium | High |
| Projection, renderers (web) | Medium | High |
| Persisted-data compatibility | Low | High |
| Interactions, approvals | Medium | High |
| Codex recovery | Medium | Medium-high |
| Mobile | Low | Medium |
| Pi | Low | Unknown until the spike |
| Performance | Medium | High |
Residual risk
- An upstream CLI changes behavior during the migration. Live QA per layer is the only catch.
- A deliberate UX change hides a real regression inside the allowlist. Every allowlist entry names its PR and its reason; the coordinator reviews additions.
- Mobile has no automated visual oracle. Simulator QA per layer and the exhaustive kind map are the floor.
- Rollback is one PR. After v3 events are persisted, a revert renders those rows as generic tools until re-applied; read-time converters stay across reverts.
Corpus — Sawyer's real sessions as test cases
Extracted 2026-08-21 from the production bb.db (read-only) into ~/.bb/provider-corpus/. Private by default: it holds real prompts, code, command output, and paths. Tests read it through BB_PROVIDER_CORPUS_DIR and skip when it is absent. A small redacted subset for CI is a separate, explicitly approved step.
| Source | Size | Status | Use |
|---|---|---|---|
bb.db events (assembled ThreadEvents) | 2,029 codex + 791 claude-code threads; 1.06M events. Extracted: 307 threads, 330,626 events, 556 MB, stratified | Imported | A4 row snapshots, read-time conversion, perf baselines (timeline build, event size), kind-set validation, unhandled ratchet |
Claude Code transcripts ~/.claude/projects | 2,122 files, 2.1 GB | Inventoried | Convert to Claude SDK message streams for the Claude translator (non-streaming paths). Converter is WS1b-claude's first task |
Codex rollouts ~/.codex/sessions | 675 files, 213 MB | Inventoried | Reference only — rollouts are not the app-server JSON-RPC the bridge consumes. Codex bridge-level recordings come from A3 |
Selection
Per provider: the 10 largest threads, 25 random threads with 200–3,000 events, and up to 12 threads per feature. Feature coverage in the corpus: goals 1 (codex has exactly one goal thread), background tasks 12, web/image 24, compaction 15, user questions 12, permission grants 7 (all that exist), non-completed turns 24, model fallback 1 (all that exist), unhandled 24, nested/subagent 24, plan updates 12, errors 24, interrupted 24, turn diffs 12, renames 12, bb tools 19.
What the production profile says about the design
| Observation | Evidence | Consequence |
|---|---|---|
| Codex plan updates are normalized and then thrown away | turn/plan/updated: 979 events across 295 codex threads; on the UI exclusion list | planSteps is a core kind; Codex update_plan and Claude TaskCreate/Update (319 calls) both feed the todo banner |
| Reads are the top generic tool | Claude Read: 7,568 completed calls, rendered by name-matching | fileRead is a core kind |
| Delegation has three encodings in the data | Claude Agent 216 + 21,572 item/backgroundTask/completed; Codex spawnAgent 614 + wait 556 | One delegation kind |
| Unhandled provider output is persisted at scale | provider/unhandled: 44,401 rows, 4.2% of all events; 927 codex and 358 claude threads | New guardrail G11: unhandled count per provider on the corpus may only go down |
| bb tools have two names and no server | Claude mcp__bb-bridge__bb_workflow_run (no server); Codex bb_workflow_result (no server) | Q31: tool definitions carry presentation; bridges emit server: "bb" + bare name |
| Suppression is real | ToolSearch 219, TaskOutput 162, Monitor 94, ScheduleWakeup 31 | presentation.suppress replaces the name table |
| MCP tools are common on Codex | js/node_repl 507; github.*/codex_apps 103 | Generic tool with server stays; per-item hints cover dynamic tools |
| Interrupted turns dominate Codex | 911 of 2,029 codex threads have a non-completed turn; 903 were interrupted | Stop and steer semantics get dedicated recordings in A3 |
Verification — every layer must pass all of these
| Gate | What runs | Pass condition |
|---|---|---|
| V1 Types | pnpm exec turbo run typecheck --filter=... for the touched packages and their dependents | Green |
| V2 Unit | Package suites for every touched package via Turbo | Green; new behavior has a test that fails before and passes after |
| V3 Conformance | The v3 conformance kit (today's 11 scenarios + presentation-on-every-item, extension item and state, recovery-hint handling, steer mode, grammar-range negotiation, delegation linkage, approval/request split) against echo and every first-party bridge | All scenarios pass for all bridges |
| V4 Goldens | Real recordings (A3) per provider replayed through the bridge; the assembled event stream and the projected rows are committed goldens | Goldens change only with --update; the PR body pastes the golden diff summary |
| V4b Parity replay | A2: the same recordings through the old bridge (pre-migration worktree) and the new bridge; events and rows diffed | Zero differences outside the allowlist; every allowlist entry names its PR and reason |
| V4c Corpus snapshots | A4: the 307 production threads re-projected with the new pipeline against the baseline row snapshots; read-time converters exercised on real legacy rows | Zero differences outside the allowlist |
| V5 Integration | tests/integration on the echo-bridge default plus dynamic-acp-agent | Green |
| V6 Live QA | Scripted matrix per touched provider with scripts/bb-dev-app and the browser: start, turn, steer, stop, approval allow and deny, question, resume after daemon restart, fork, model list, usage and health, skills typeahead, plan mode. Screenshots land in thread storage; the checklist lands in the PR | Every cell passes on every touched provider |
| V7 Mobile | iOS Simulator Safari on a long thread that contains every touched kind, plus the extension fallback | Every kind renders from the declarative base; no "unsupported" card for a core kind |
| V8 Review | CI, SlopCop, and a coordinator review through the close-out flow | Required checks green; findings resolved |
V3, V4, and V5 run in CI on every PR. V6 and V7 run on every layer that touches a bridge, the assembler, projection, or a renderer, and again before the stabilization PR for all providers.
Performance — budgets, measured against a committed baseline
Baselines are recorded once on main before the contract PR, on a committed fixture corpus: one 10k-event thread per provider from the calibration recordings, plus a 50-thread project. A budget breach fails CI.
| Metric | How it is measured | Budget | Why it can regress |
|---|---|---|---|
| Timeline build time | Benchmark test over buildThreadTimeline on the fixture threads, reporting the existing ThreadTimelineBuildProfile phases; p50 and p95 | ≤ baseline +10%; zero lines from the 150 ms slow-build log during V6 | Rows gain presentation; projection moves from name tables to kinds |
| Assembler throughput and heap | Benchmark over recorded delta streams: events per second; heap per open thread after 10k events | ≤ baseline +10% time; no growth in retained id-map size per settled item | Generic extension handling; presentation passthrough |
| Persisted event size | Median and p95 bytes per event row on the fixture corpus after replay | ≤ +15% median; detail ≤ 280 chars, labels ≤ 80, enforced by schema | Presentation is persisted with every item |
| Query plans | packages/db/test/query-plans.test.ts extended: planSteps head state, open delegations, extension state, goal conversion | No SCAN on the events table; the kind index is used | The tool_name virtual column and index are replaced by a kind index |
| Migration time | The kind-index migration on a fixture DB with 1M event rows | ≤ 5 s; runs in one transaction | Index build over a large table |
| Server event loop | Stall attribution (PR #1437 instrumentation) over a scripted 10-minute multi-thread session in V6 | No new stall source attributed to projection, assembly, or interaction handling; p95 stall unchanged | Schema validation at ingest; projection changes |
| App boot payload | check-bundle-budget.mjs and a test that no provider plugin bundle is fetched before a thread opens | Boot payload unchanged; zero provider bundles at boot | Plugin renderers and lazy loading |
| Style recalculation | The existing theme.test.ts guard; the :where() scoping rule for every plugin stylesheet | No @scope; recalculation on the long-thread fixture within baseline | Provider plugin stylesheets |
| Mobile row model | Benchmark over the mobile row model on the same fixture | ≤ baseline +10% | Declarative base rendering |
Guardrails — static, enforced in CI, ratcheting toward zero
| Guardrail | Mechanism | Start | End state |
|---|---|---|---|
| G1 Provider-literal ratchet | A test greps core (everything outside plugins/provider-* and examples/) for provider ids and Claude/Pi tool names against a committed allowlist. The count may only go down | Seeded from the census: 178 non-test hits | 0; the allowlist file is deleted |
| G2 Contract purity | Type-level test: keys of RuntimeThreadExecutionOptions, ProviderInfo, the daemon session payload, and AppSettings never match /codex|claude|pi|acp|cursor/i | Allowlisted today's fields | Empty allowlist |
| G3 Grammar version discipline | Snapshot of the v3 schema JSON paired with PROVIDER_BRIDGE_PROTOCOL_VERSION; a schema change without a bump fails. Mirrors the HOST_DAEMON_PROTOCOL_VERSION rule | From the contract PR | Permanent |
| G4 Presentation coverage | Schema rejects item.open without presentation; exhaustive-switch tests prove the web and mobile renderer maps cover every core kind plus the extension fallback | From the contract PR | Permanent |
| G5 First-party purity | First-party provider plugins' server.ts, app.tsx, and bridge import only @get-bb/plugin-sdk; the host-artifact rule extends to server and app entries | Allowlisted today's @bb/* imports | Empty allowlist |
| G6 Third-party canary | The echo provider runs the full conformance kit and the integration suite on every PR | Exists | Permanent |
| G7 Daemon wire discipline | The existing HOST_DAEMON_PROTOCOL_VERSION rule plus prior-version compat fixtures | Exists | Permanent |
| G8 Golden immutability | Goldens regenerate only through a script with --update; CI fails when output differs from the committed golden | Exists for calibration | Extended to projected rows |
| G9 Audit entries | A test that every experimental_ export on the surface has a docs/api_to_audit.md entry | From WS2a | Deleted with the stabilization PR |
| G10 Doc–type sync | A test compiles the code blocks of docs/provider-plugin-api.md against the real types | From the contract PR | Permanent |
| G11 Unhandled ratchet | Replaying the A3 recordings and the corpus, the count of provider/unhandled per provider is recorded; a layer may not raise it | 44,401 rows in production today | Near zero on recordings; permanent |
| G12 Permission matrix | A5: every (permission mode × approval subject × approvalEnforcedBy) cell pinned before WS5 | Before the contract PR | Permanent |
Risks and rollback
- Rollback unit is one PR. Each layer reverts cleanly. After v3 events are persisted, a revert renders those rows as generic tools until re-applied; the read-time converters stay in place across reverts.
- Pi RPC mode may lack what the SDK path has (tool proxy, model list, OAuth). The spike decides before WS4 schedules the rewrite.
- Goldens will change on purpose. Byte-equivalence with the old translators is abandoned. The coordinator reads every golden diff; V6 live QA is the second line.
- Amp (the only external provider) uses the
customAcpAgentsconfig path. The ACP plugin reads it for two minor releases with a deprecation log; a migration issue goes tosmsunarto/bb-pluginswhen the ACP kit ships. - Enrolled daemons update through the normal protocol bump. Wire changes are batched into as few layers as possible, one bump each.