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

  1. Zero first-party privilege. First-party providers use only the public API. Every special case becomes a public primitive or is deleted.
  2. Each fact lives in one place. A capability is declared or reported, never both. Presentation comes from the bridge, never from core tables.
  3. Core understands a small semantic vocabulary. Everything else is an extension kind with mandatory declarative presentation.
  4. 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 }

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

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()

6. Settings and product surfaces

7. Deleted from core

AreaGoes away
Pipackages/agent-runtime/src/pi/**, the daemon-bundled source kind, DAEMON_BUNDLED_PROVIDER_BRIDGE_IDS, pi SDK deps in runtime and daemon
Codex runtimethree error regexes, account-restart set, per-thread process keys, rename retry, archive idempotency string match, the daemon per-thread lane mirror
ACP tieracp-provider-tier.ts, customAcpAgents core config, typed acpLaunchSpec on 8 daemon commands, 3× isAcpProviderId, the ACP logo route
Shared contractsclaudeCodePermissionMode, workflowsEnabled, memoryEnabled, providerSubagentsEnabled; the five claude/codex settings keys; supportsWorkflows, experimental_visibility, goalClear
Projectionclaude-task-tools.ts, thread-view tool-name tables, tool-call-suppression.ts, todo extraction by tool name, statusLabels, the tool_name virtual column
Catalogsthree Claude model tables, PLACEHOLDER_PROVIDER_INFOS, RESERVED_PROVIDER_ID_OWNERS, PRODUCT_PROVIDER_ORDER
Daemoncodex.inference.complete, codex.voice.transcribe, codex-auth.ts, the ChatGPT client, BB_CLAUDE_CODE_EXECUTABLE passthrough
Teststhe ProviderAdapter interface and the 674-line fake adapter; the echo bridge becomes the harness default
Namingevery 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

Baselines PRcorpus reader (BB_PROVIDER_CORPUS_DIR), row snapshots of the 307 threads, timeline-build and event-size baselines, permission matrix pinned (A4, A5, G12)
Record mode + recordings PRbridge record mode; live-QA matrix run per provider; redacted recordings as fixtures; parity harness resurrected (A2, A3)
Target docdocs/provider-plugin-api.md, no phases
Contract PRv3 schemas (additive), domain union, TimelineRow, declaration type, ProviderInfo, interaction split, mobile kind-map test. Types only.

Step 1 — parallel

WS1a assembler + kitgeneric assembler accepting v2 and v3 (A1), recovery plumbing, range negotiation, published testing kit, echo as harness default
WS2a registrybb.providers.register, strings, options hook, promptMode, flat ids, single ProviderInfo, useProviders()
WS2b ACPACP kit, plugin owns agents, delete the tier, daemon bump, Amp migration issue
Pi spikepi --mode rpc through the conformance kit; report gaps

Step 2 — after WS1a layer 1

WS1b ×3claude, codex, acp translations to v3 with presentation, one per layer; parity replay green per bridge; pi migrated in place; the top layer deletes v2 (A1). Claude: transcript → SDK-stream converter first
WS3 projection + renderers + mobilerows from kinds, delete name tables, kind index migration, renderer slot, lazy bundles, mobile base + usage bug
WS4 runtime + process + pirecovery hints, delete codex regexes and per-thread keys, steerMode, skills methods, pi rewrite, AI services
WS5 interactionsclosed approvals, open requests, one lifecycle event, bridge mapping, planReview

Step 3 — coordinator

Stabilization PRdrop every experimental_, clear api_to_audit.md
Final censusprovider-literal ratchet at zero; delete the allowlist

Regression 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.

AdditionWhat it isWhat it catches
A1 Additive-then-delete inside every stackLayer 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 replayResurrect 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 recordingsA 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 snapshotsThe 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 firstOne 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

AreaNowWith A1–A5
Bridge translationHighHigh
Registry, ids, stringsHighHigh
Assembler, grammarMediumHigh
Projection, renderers (web)MediumHigh
Persisted-data compatibilityLowHigh
Interactions, approvalsMediumHigh
Codex recoveryMediumMedium-high
MobileLowMedium
PiLowUnknown until the spike
PerformanceMediumHigh

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.

SourceSizeStatusUse
bb.db events (assembled ThreadEvents)2,029 codex + 791 claude-code threads; 1.06M events. Extracted: 307 threads, 330,626 events, 556 MB, stratifiedImportedA4 row snapshots, read-time conversion, perf baselines (timeline build, event size), kind-set validation, unhandled ratchet
Claude Code transcripts ~/.claude/projects2,122 files, 2.1 GBInventoriedConvert to Claude SDK message streams for the Claude translator (non-streaming paths). Converter is WS1b-claude's first task
Codex rollouts ~/.codex/sessions675 files, 213 MBInventoriedReference 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

ObservationEvidenceConsequence
Codex plan updates are normalized and then thrown awayturn/plan/updated: 979 events across 295 codex threads; on the UI exclusion listplanSteps is a core kind; Codex update_plan and Claude TaskCreate/Update (319 calls) both feed the todo banner
Reads are the top generic toolClaude Read: 7,568 completed calls, rendered by name-matchingfileRead is a core kind
Delegation has three encodings in the dataClaude Agent 216 + 21,572 item/backgroundTask/completed; Codex spawnAgent 614 + wait 556One delegation kind
Unhandled provider output is persisted at scaleprovider/unhandled: 44,401 rows, 4.2% of all events; 927 codex and 358 claude threadsNew guardrail G11: unhandled count per provider on the corpus may only go down
bb tools have two names and no serverClaude 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 realToolSearch 219, TaskOutput 162, Monitor 94, ScheduleWakeup 31presentation.suppress replaces the name table
MCP tools are common on Codexjs/node_repl 507; github.*/codex_apps 103Generic tool with server stays; per-item hints cover dynamic tools
Interrupted turns dominate Codex911 of 2,029 codex threads have a non-completed turn; 903 were interruptedStop and steer semantics get dedicated recordings in A3

Verification — every layer must pass all of these

GateWhat runsPass condition
V1 Typespnpm exec turbo run typecheck --filter=... for the touched packages and their dependentsGreen
V2 UnitPackage suites for every touched package via TurboGreen; new behavior has a test that fails before and passes after
V3 ConformanceThe 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 bridgeAll scenarios pass for all bridges
V4 GoldensReal recordings (A3) per provider replayed through the bridge; the assembled event stream and the projected rows are committed goldensGoldens change only with --update; the PR body pastes the golden diff summary
V4b Parity replayA2: the same recordings through the old bridge (pre-migration worktree) and the new bridge; events and rows diffedZero differences outside the allowlist; every allowlist entry names its PR and reason
V4c Corpus snapshotsA4: the 307 production threads re-projected with the new pipeline against the baseline row snapshots; read-time converters exercised on real legacy rowsZero differences outside the allowlist
V5 Integrationtests/integration on the echo-bridge default plus dynamic-acp-agentGreen
V6 Live QAScripted 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 PREvery cell passes on every touched provider
V7 MobileiOS Simulator Safari on a long thread that contains every touched kind, plus the extension fallbackEvery kind renders from the declarative base; no "unsupported" card for a core kind
V8 ReviewCI, SlopCop, and a coordinator review through the close-out flowRequired 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.

MetricHow it is measuredBudgetWhy it can regress
Timeline build timeBenchmark 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 V6Rows gain presentation; projection moves from name tables to kinds
Assembler throughput and heapBenchmark 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 itemGeneric extension handling; presentation passthrough
Persisted event sizeMedian and p95 bytes per event row on the fixture corpus after replay≤ +15% median; detail ≤ 280 chars, labels ≤ 80, enforced by schemaPresentation is persisted with every item
Query planspackages/db/test/query-plans.test.ts extended: planSteps head state, open delegations, extension state, goal conversionNo SCAN on the events table; the kind index is usedThe tool_name virtual column and index are replaced by a kind index
Migration timeThe kind-index migration on a fixture DB with 1M event rows≤ 5 s; runs in one transactionIndex build over a large table
Server event loopStall attribution (PR #1437 instrumentation) over a scripted 10-minute multi-thread session in V6No new stall source attributed to projection, assembly, or interaction handling; p95 stall unchangedSchema validation at ingest; projection changes
App boot payloadcheck-bundle-budget.mjs and a test that no provider plugin bundle is fetched before a thread opensBoot payload unchanged; zero provider bundles at bootPlugin renderers and lazy loading
Style recalculationThe existing theme.test.ts guard; the :where() scoping rule for every plugin stylesheetNo @scope; recalculation on the long-thread fixture within baselineProvider plugin stylesheets
Mobile row modelBenchmark over the mobile row model on the same fixture≤ baseline +10%Declarative base rendering

Guardrails — static, enforced in CI, ratcheting toward zero

GuardrailMechanismStartEnd state
G1 Provider-literal ratchetA 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 downSeeded from the census: 178 non-test hits0; the allowlist file is deleted
G2 Contract purityType-level test: keys of RuntimeThreadExecutionOptions, ProviderInfo, the daemon session payload, and AppSettings never match /codex|claude|pi|acp|cursor/iAllowlisted today's fieldsEmpty allowlist
G3 Grammar version disciplineSnapshot of the v3 schema JSON paired with PROVIDER_BRIDGE_PROTOCOL_VERSION; a schema change without a bump fails. Mirrors the HOST_DAEMON_PROTOCOL_VERSION ruleFrom the contract PRPermanent
G4 Presentation coverageSchema rejects item.open without presentation; exhaustive-switch tests prove the web and mobile renderer maps cover every core kind plus the extension fallbackFrom the contract PRPermanent
G5 First-party purityFirst-party provider plugins' server.ts, app.tsx, and bridge import only @get-bb/plugin-sdk; the host-artifact rule extends to server and app entriesAllowlisted today's @bb/* importsEmpty allowlist
G6 Third-party canaryThe echo provider runs the full conformance kit and the integration suite on every PRExistsPermanent
G7 Daemon wire disciplineThe existing HOST_DAEMON_PROTOCOL_VERSION rule plus prior-version compat fixturesExistsPermanent
G8 Golden immutabilityGoldens regenerate only through a script with --update; CI fails when output differs from the committed goldenExists for calibrationExtended to projected rows
G9 Audit entriesA test that every experimental_ export on the surface has a docs/api_to_audit.md entryFrom WS2aDeleted with the stabilization PR
G10 Doc–type syncA test compiles the code blocks of docs/provider-plugin-api.md against the real typesFrom the contract PRPermanent
G11 Unhandled ratchetReplaying the A3 recordings and the corpus, the count of provider/unhandled per provider is recorded; a layer may not raise it44,401 rows in production todayNear zero on recordings; permanent
G12 Permission matrixA5: every (permission mode × approval subject × approvalEnforcedBy) cell pinned before WS5Before the contract PRPermanent

Risks and rollback