7. Provider bridges — @get-bb/plugin-sdk/bridge
@get-bb/plugin-sdk/bridge is the surface a provider plugin's bridge (host role provider-bridge, a child process) and its server half share. It re-exports @bb/bridge-kit/runtime — defineBridge, BridgeRuntime, runBridge, childEnv, autoDeny, every protocol v3 schema, and the declaration helpers — and adds defineProviderDeclaration and bridgeHostRole. Source: next/packages/plugin-sdk/src/bridge/index.ts. Kernel: next/packages/bridge-kit/src/ (paths below are relative to it unless noted). Spec: 06 §2–§4, §7–§9. Where spec and build differ, the build wins and the row says so. Bridges may be written in any language (06 §4, A11); the kit is a convenience over line-delimited JSON-RPC 2.0 on stdio.
7.1 A provider plugin's anatomy
A provider plugin is an ordinary plugin with three halves: server.ts (hooks), app.tsx (renderers, optional), host.ts (the bridge). Only the declaration and the bridge are mandatory (06 §2; 11c §0).
| Manifest row | Content | Spec | As built |
|---|---|---|---|
bb.contributes.providers[] |
ProviderDeclaration[]; bb plugin build inlines src/declaration.ts so the manifest stays data |
06 §2, §3 | declaration.ts providerDeclarationSchema |
bb.contributes.hostRoles[] |
{role: "provider-bridge", name: <provider id>, launch: {kind: "module", export} | {kind: "exec", command, args}, limits}; declaration.bridge equals name |
06 §2; 05 §6.5 | bridgeHostRole() builds the module row |
bb.contributes.eventKinds |
one KindDeclarationInput per extension kind; the declaration repeats the names in extensionKinds |
06 §2; 04 §5.2 | the assembler compiles these schemas (§7.12) |
bb.contributes.interactionKinds |
{schema, resolutionSchema} per open request kind; names repeated in interactionKinds |
06 §2; 04 §3.6 | loader-owned; no kit schema (README BK-7) |
bb.provides["providers/hooks"] |
the multi hook contract, factsKey: "providerId" |
06 §3.1 | contract not in the next tree; providerFacts() is |
bb.requires["providers/registry"], bb.uses["skills/catalog"] |
service edges; @get-bb/plugin-sdk/bridge is a package import, not an edge |
11c §0 | — |
bb.contributes.settings, slots, icons |
declarative settings, claimed slot names, <pluginId>/<icon> |
06 §2 | section 1 |
bb.host |
defineHostEntry({ roles: { "provider-bridge": { <name>: defineBridge(...) } } }) |
06 §2; 05 §6.2 | plugin-sdk/src/host/index.ts |
// host.ts
import { defineHostEntry } from "@get-bb/plugin-sdk/host";
import { defineBridge } from "@get-bb/plugin-sdk/bridge";
export default defineHostEntry({
roles: { "provider-bridge": { codex: defineBridge(codexBridge) } },
signals: {}, async dispose() {},
});Server half (06 §3.1): one ctx.provide(providerHooks, { deriveProviderOptions, bridgeEnv, modelCatalog }, providerFacts(declaration)) per provider. deriveProviderOptions runs once per thread.create|send|fork and becomes ExecutionOptions.providerOptions (2 s timeout; 502 provider_options_failed). bridgeEnv becomes RoleSpec.env for the bridge child (BB_* names rejected with bridge_env_reserved; 502 bridge_env_failed). modelCatalog returns ModelDecl[] | null; null means use the bridge probe. A plugin with no server half gets {} options, {} env, and the probe. As built, the providers plugin and the providerHooks contract are spec-only in next/; providerFacts exists.
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
defineProviderDeclaration |
(input: ProviderDeclaration) => ProviderDeclaration |
parses with providerDeclarationSchema; throws KernelError {code: "invalid_contract", issues} naming the provider id and each failing path |
06 §3 | plugin-sdk/src/bridge/index.ts |
bridgeHostRole |
(declaration, exportName = declaration.bridge) => {role: "provider-bridge", name: declaration.bridge, launch: {kind: "module", export: exportName}} |
the contributes.hostRoles[] row for a module bridge |
06 §2; 05 §6.5 | plugin-sdk/src/bridge/index.ts |
providerFacts |
(decl) => {providerId, nativeUserQuestion, permissionModes, approvals, features} |
the providers/hooks facts, computed so they are never typed twice |
06 §3.1 | declaration.ts |
7.2 ProviderDeclaration
Every field is required; nothing defaults (06 §3). Validated by the loader without executing code and again by defineProviderDeclaration. As built: declaration.ts.
| Field | Type | Meaning |
|---|---|---|
id |
/^[a-z0-9][a-z0-9-]{1,63}$/ |
flat provider id; persisted on threads; a collision across plugins is an install-time error |
family |
"native" | "acp" |
acp = the bridge speaks ACP to a child named in bridgeOptions.launch; core never branches on it |
bridge |
string (min 1) |
name of the provider-bridge host role row |
icon |
string (min 1) |
<pluginId>/<iconName> from contributes.icons |
strings |
{name, short, description, modelBrandPrefix: string | null} |
display strings |
permissionModes |
PermissionMode[] (min 1) |
subset of accept-edits < auto < full; a request outside it is 400 permission_mode_unsupported |
reasoningLevels |
{id, label, rank: int, description}[] |
open list, low to high; rank is one shared integer scale for cross-provider reconciliation (06 §7.3) |
serviceTiers |
{id, label, description}[] |
open list; [] = no tier toggle |
fork |
"none" | "tip" | "checkpoint" |
UI ceiling; the handshake may narrow it, never widen it |
nativeUserQuestion |
boolean |
the provider asks questions natively (a fact) |
manualCompaction |
boolean |
the compact composer action exists |
maintenance |
{health, usage, installation} booleans |
gates the sessionless provider/* methods |
visibility |
"always" | "installed" |
installed: listed only on hosts whose provider/health is not not_installed |
features |
string[] |
open facts sibling plugins query ("workflows", "goal"); core reads none |
composerActions |
{id, trigger, label, description, sticky}[] |
ids ride turn/start.actions; sticky stays armed across turns |
approvals |
ApprovalSubjectKind[] |
closed subjects the bridge may request; feeds the facts (a kit addition, README "Notes") |
extensionKinds |
ExtensionKindName[] |
names only; each must exist in contributes.eventKinds |
interactionKinds |
ExtensionKindName[] |
names only; each must exist in contributes.interactionKinds |
models |
{catalog: "bridge" | "fallback-only", fallback: ModelDecl[]} |
bridge calls model/list; fallback serves offline, on probe failure, or always for fallback-only |
skills |
{scanRoots: SkillScanRoot[], discover: boolean} |
declarative native-skill roots; discover additionally enables skills/discover |
bridgeOptions |
Record<string, JsonValue> |
static launch data, opaque to core, sent on every sessionless call and in SessionParams |
7.3 defineBridge, runBridge, BridgeRuntime
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
defineBridge |
(bridge: ProviderBridge) => BridgeDefinition |
returns {apiVersion: 1, kind: "provider-bridge", bridge}; throws when declarations is empty |
06 §2 | runtime/bridge.ts |
BridgeRuntime |
new (def, io: {write(line)}); handleLine(line), end(), closed: Promise<void>, context: BridgeContext |
JSON-RPC peer over a line writer: negotiates, parses params, gates methods, dispatches, parses results | 06 §4.1–§4.3 | runtime/bridge.ts |
runBridge |
(def, io = process, maxLineBytes?) => Promise<void> |
pumps stdin lines through a LineDecoder into handleLine, calls end() at EOF, resolves after closed |
06 §4.1 | runtime/bridge.ts |
childEnv |
(sessionEnv, processEnv = process.env) => Record<string, string> |
the bridge's env minus every BB_* name, overlaid with SessionParams.env; use it for every agent shell the bridge spawns |
06 §4.3, §6.1 | runtime/env.ts |
autoDeny |
(policy: PermissionPolicy, request: InteractionRequest) => InteractionResolution | null |
{kind: "approval", decision: "deny", granted: null} when the request is an approval and policy.escalation === "deny"; null otherwise |
06 §8 | runtime/env.ts |
ProviderBridge is { declarations: ProviderDeclaration[]; initialize(params: InitializeParams, ctx): BridgeCapabilities; methods: BridgeMethods }. Required handlers: thread/start, turn/start, thread/stop, thread/discard, shutdown. Every other method is optional; an advertised method with no handler answers METHOD_NOT_FOUND.
Dispatch (runtime/bridge.ts invoke): parse params (INVALID_PARAMS with data.issues) → pick the declaration by params.providerId (the first declaration when the method carries none; an unknown id is INVALID_PARAMS) → isMethodAdvertised (CAPABILITY_NOT_ADVERTISED) → thread/fork with a non-null sourceCheckpointId needs capabilities.fork === "checkpoint" (FORK_CHECKPOINT_UNSUPPORTED) → the handler → the result is parsed with coreToBridgeSpecs[method].result. Any method before initialize is BRIDGE_ERROR. shutdown is answered {} first; then it, or EOF (which runs the shutdown handler with graceMs: 0), answers every outstanding request with INTERACTION_INTERRUPTED. Core sends the bridge no notifications.
BridgeContext, the ctx every handler receives:
| Member | Signature | What it does |
|---|---|---|
capabilities |
BridgeCapabilities |
the negotiated facts; throws before initialize |
sendThreadDeltas |
(threadId, deltas: Delta[]) => void |
the only timeline lane; validated with threadDeltaParamsSchema; an invalid delta throws (a bridge bug) |
emitForSession |
(threadId, {method, params}) => void |
thread/identity, session/replaced, thread/openWork, provider/recovery with threadId added |
recovery |
(threadId: string | null, {hint, reason, retryable}) => void |
provider/recovery; null is process-wide |
log |
(level, message, threadId = null) => void |
bridge/log |
raw |
(threadId | null, coverage: "noise" | "unknown", payload) => void |
provider/raw; droppable, never persisted |
toolCall |
(params) => Promise<{success, content, ui}> |
blocking tool/call |
requestInteraction |
(params) => Promise<InteractionResolution> |
blocking interaction/request |
childEnv, autoDeny |
as above | bound helpers |
Core → bridge methods (06 §4.3; protocol/requests.ts coreToBridgeRequests). S = {providerId, cwd, bridgeOptions}; Ref = {threadId, providerThreadId}; SP = SessionParams (§7.9).
| Method | Params | Result | Gate |
|---|---|---|---|
initialize |
InitializeParams |
InitializeResult |
— (runtime-owned; calls bridge.initialize) |
model/list |
S |
{models: ModelDecl[]} |
models.catalog === "bridge" |
provider/health |
S |
{supported: false} | {supported: true, health: ProviderHealth} |
maintenance.health |
provider/usage |
S |
{supported: false} | {supported: true, usage: ProviderUsage} |
maintenance.usage |
provider/installation/status |
S & {requirement: "thread_rewind" | null} |
InstallationStatus |
maintenance.installation |
provider/installation/run |
S & {action: "install" | "update"} |
{available: false, message} | {available: true, command: InstallCommand, verification: InstallVerification} |
maintenance.installation |
skills/discover |
S & {scopes: ("user" | "project")[]} |
{skills: DiscoveredSkill[]} |
skills.discover |
skills/configure |
{threadId, roots: SkillRoot[]} |
{} |
— |
thread/start |
SP & {input: PromptInput[] | null} |
SessionIdentity |
— (required) |
thread/resume |
SP & {providerThreadId} |
SessionIdentity |
sessionRestore |
thread/fork |
SP & {sourceProviderThreadId, sourceCheckpointId: string | null} |
SessionIdentity |
fork !== "none"; checkpoint needs "checkpoint" |
thread/stop |
Ref & {intent: "interrupt" | "release", activeTurnId: string | null} |
{} |
— (required) |
thread/discard |
Ref |
{} |
— (required) |
thread/name/set |
Ref & {title} |
{} |
threadRename |
thread/archive, thread/unarchive |
Ref |
{} |
threadArchive |
thread/goal/clear |
Ref |
{} |
threadGoal |
turn/start |
TurnStartParams (TurnParams without turnId) |
{} |
— (required) |
turn/steer |
TurnSteerParams (turnId = the provider's key for the running turn) |
{} |
— |
shutdown |
{graceMs: int ≥ 0} |
{} |
— (required) |
thread/stop {intent: "interrupt"} expects settlement deltas within the stop grace; release expects none. Compaction is not a method: it is turn/start with actions: ["compact"] and empty input; a refused compaction settles with provider.warning {category: "compaction-skipped"} (06 §4.3).
Bridge → core requests (06 §4.4; bridgeToCoreRequests). Both block until core answers. Keys are provider-native; the host driver maps them through the assembler. requestKey forms the dedupe key <providerId>:<providerThreadId>:<requestKey>.
| Method | Params | Result |
|---|---|---|
tool/call |
{threadId, providerThreadId, turnKey: string | null, callId, tool, arguments: JsonValue} |
{success, content: ToolContent[], ui: {renderer, payload} | null} |
interaction/request |
{threadId, providerThreadId, turnKey: string | null, requestKey (min 1), request: InteractionRequest} |
InteractionResolution |
Notifications, bridge → core (06 §4.5; protocol/notifications.ts bridgeNotifications). thread/delta is strict; the rest are additive.
| Method | Params | Notes |
|---|---|---|
thread/delta |
{threadId, deltas: Delta[]} |
the only timeline lane; consumed by the assembler on the host |
thread/identity |
{threadId, providerThreadId, sessionRestorable} |
precedes the session's first delta; becomes thread.identity |
session/replaced |
{threadId, providerThreadId | null, reason, contextLost} |
mandatory on every rebuild, before new traffic; becomes provider.warning {category: "session-replaced"} |
thread/openWork |
{threadId, open} |
level-triggered; read by the reaper |
provider/recovery |
{threadId | null, hint: RecoveryHint, reason, retryable} |
typed hints; the providers plugin acts |
provider/raw |
{threadId | null, coverage: "noise" | "unknown", payload} |
record mode only |
bridge/log |
{level: debug|info|warn|error, threadId | null, message} |
routed to the plugin log |
export const codexBridge = defineBridge({
declarations: [codexDeclaration],
initialize: () => ({ sessionRestore: true, threadArchive: false, threadRename: false, threadGoal: true,
fork: "checkpoint", approvalEnforcedBy: "runtime", steer: "native" }),
methods: {
"thread/start": async (p, ctx) => {
ctx.emitForSession(p.threadId, { method: "thread/identity", params: { providerThreadId: id, sessionRestorable: true } });
ctx.sendThreadDeltas(p.threadId, [{ kind: "session.reset" }]);
return { providerThreadId: id, sessionRestorable: true };
},
"turn/start": async (p, ctx) => { /* input.accepted, turn.open, items…, turn.boundary */ return {}; },
"thread/stop": async () => ({}), "thread/discard": async () => ({}), shutdown: async () => ({}),
},
});7.4 Handshake and capabilities
initialize carries {protocol: {min, max}, client: {name: "bb", version}, plugin: {id, version}, providerIds} and answers {protocol, capabilities} (06 §4.2; protocol/handshake.ts). The runtime picks the highest version in both ranges; no intersection is PROTOCOL_UNSUPPORTED with {core, bridge} in error.data. It then parses the bridge's capabilities and checks every declaration with capabilityWidens. A second initialize is BRIDGE_ERROR. Versioning rule: the delta grammar is strict and enters persistence, so any grammar change bumps max; requests and results are looseObject (// additive-wire) and may gain fields without a bump.
| Capability | Type | Gates |
|---|---|---|
sessionRestore |
boolean | thread/resume |
threadArchive |
boolean | thread/archive, thread/unarchive |
threadRename |
boolean | thread/name/set |
threadGoal |
boolean | thread/goal/clear |
fork |
"none" | "tip" | "checkpoint" |
thread/fork; checkpoint forks; may only narrow declaration.fork |
approvalEnforcedBy |
"runtime" | "provider" |
who auto-denies approvals (§7.8) |
steer |
"native" | "queue" |
queue: the bridge holds a steer until the turn ends |
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
negotiateProtocol |
(a: ProtocolRange, b: ProtocolRange) => number | null |
min(a.max, b.max) when it is ≥ max(a.min, b.min), else null |
06 §4.2 | protocol/handshake.ts |
capabilityWidens |
(caps, decl) => string | null |
FORK_RANK[caps.fork] > FORK_RANK[decl.fork] → "fork: … widens declared …"; the runtime answers BRIDGE_ERROR with data.rule: "handshake/narrows-only" (06 §4.9 assigns no code) |
06 §4.2 | declaration.ts |
isMethodAdvertised |
(method, caps, decl) => boolean |
METHOD_GATES[method]?.(caps, decl) ?? true; ungated methods are always sent |
06 §4.3 gate column | declaration.ts |
7.5 The delta grammar
thread/delta {threadId, deltas} is strict (protocol/deltas.ts deltaSchema; 06 §4.6). Scoping is explicit: the eight kinds in SCOPED_DELTA_KINDS carry scope: "turn", turnKey or scope: "thread" (scopeSchema), with no default. turnKey is z.string().min(1).nullable(); null means the bridge's current open turn, and a non-null key must be one this bridge opened. The "04 event" column is what the host assembler emits; the bridge never sees it.
| Kind | Fields | Scope | 04 event |
|---|---|---|---|
input.accepted |
clientRequestId (min 1), turnKey |
turn | turn.accepted; held until its turn opens |
turn.open |
turnKey, parentRef: string | null |
turn | turn.started under the core-minted id bound FIFO (R4) |
turn.boundary |
status: completed|failed|interrupted, error: {message} | null, checkpointId | null, claimIfIdle, turnKey |
turn | turn.completed |
turn.diff |
files: DiffFile[], turnKey |
turn | turn.diff, keep-latest per turn |
item.open |
key, item, presentation + Scope |
explicit | {kind, phase: "open", payload + providerItemId, presentation}; itemId minted here |
item.progress |
key, progress: JsonValue, presentation | null + Scope |
explicit | set {"$.progress"}; one event each, never throttled |
item.close |
key, status, item, presentation, approval: "denied" | null + Scope |
explicit | phase: "close"; the close shape wins |
text.delta |
key, channel: message|reasoning|reasoningSummary|plan, text, part (int for reasoningSummary, null otherwise), turnKey |
turn | append $.text or $.summary[part]; an unknown key synthesizes an open |
output.delta |
key, text, turnKey |
turn | append $.output (command); unknown key buffered ≤ 256 KiB |
output.snapshot |
key, text, turnKey |
turn | set $.output verbatim; nothing diffs |
todo |
items: TodoItem[], explanation | null + Scope |
explicit | todo, keep-latest per turn |
usage |
last: TokenBreakdown, total | null, contextWindow: {used | null, size | null, estimated} | null + Scope |
explicit | usage; total: null is accumulated |
context.cleared |
— | thread | thread.context_cleared |
thread.name |
name |
thread | thread.renamed |
provider.rateLimits |
rateLimits: RateLimitState |
thread | provider.rate_limits, keep-latest |
provider.error |
message, detail | null, category, providerCode | null, httpStatus | null, willRetry, settlesTurn + Scope |
explicit | provider.error; settlesTurn closes the turn and its items failed |
provider.warning |
message, detail | null, category + Scope |
explicit | provider.warning |
provider.modelFallback |
from, to, reason: refusal|provider, message |
thread | provider.model_fallback, one per (from, to) per turn |
unhandled |
raw: JsonValue, rawType + Scope |
explicit | provider.unhandled |
session.ended |
— | — | no event; settles the open turn and items interrupted |
session.reset |
— | — | no event; flushes buffers, re-draws id entropy; first delta of every session |
Rules a bridge must hold (06 §4.6, §5.2): a turn-scoped delta with no open turn is never dropped — it becomes a thread-scoped provider.unhandled (turnless/unhandled); an unrequested turn.open (no handed-in turn id) takes the same path (turn/bound-to-requested); scope: "thread" is for a task that finishes after its turn or a compaction between turns; an item's scope is fixed at open. The driver enforces GRAMMAR_RULE_IDS live (grammar/rules.ts): events/schema-valid, scope/explicit, item/presentation-present, item/extension-kind-declared, turn/vouched-keys-only, item/opens-before-delta, item/settles-once; The driver drops a delta that breaks a rule and logs bridge/log warn naming the rule.
7.6 Items
An item key (itemKeySchema, 06 §4.6/§4.10) is {providerItemId, channel, parentRef}, each string | null, at least one non-null, no part containing U+001F (ITEM_KEY_SEPARATOR). The key string is the parts joined by U+001F; parentRef names a parent by its key string or its providerItemId and becomes parentItemId. providerItemId is copied into payload.providerItemId by the assembler for correlation and is never the stored id (R4). itemStatusSchema is pending | completed | failed | interrupted; denied is approval, not a status. Payloads are strict (protocol/items.ts coreItemSchema), snake_case (R27; D11 built wins).
| Kind | Payload |
|---|---|
message |
{role: "assistant", text: string | null, status}; text: null on close keeps the streamed text |
reasoning |
{summary: string[], text, status}; persisted with suppress: true (R28) |
plan |
{text | null, status} plan-mode prose |
command |
{command, cwd, intent: RenderIntent, output | null, exitCode | null, durationMs | null, approval: ApprovalState, status} |
file_change |
{changes: FileChange[], approval, status}; FileChange = {path, kind: add|update|delete, movePath | null, diff | null, oldText | null, newText | null} |
tool |
{name, server | null, args, result, error | null, intent, durationMs | null, progress | null, ui: {renderer, payload} | null, approval, status} |
web_search |
{queries: string[], results | null, status} |
web_fetch |
{url, prompt | null, summary | null, status} |
image_view |
{path, status} |
task |
TaskPayload: {role: delegation|background|workflow, familyId, name, description, prompt | null, childThreadId | null, taskStatus: pending|running|paused|completed|failed|killed|stopped, skipTranscript, progress | null, usage: {totalTokens, toolUses, durationMs} | null, summary | null, error | null, outputFile | null, status}; delegations, background shells, workflows |
compaction |
{summary | null, status} |
<pluginId>/<kind> |
extensionItemSchema: JsonObject that does not set providerItemId; validated on the host against the contributes.eventKinds schema; item: false kinds are refused on the item plane |
detachesMessage(kind) is true for command, tool, file_change, task, and every extension kind: their item.open closes the open message stream in the same scope (message/auto-detach). extensionKindNameSchema enforces EXTENSION_KIND_PATTERN. Delegation is task {role: "delegation"} with child items nested through parentRef; todo is a fact, not an item (06 §4.7).
7.7 Presentation and render intents
presentationSchema (06 §4.7, I16; protocol/common.ts): {label (min 1), icon (min 1), title?, detail?, suppress?, tint?: neutral|info|success|warning|danger}. The optionals are the only optionals in the grammar; omission means "no such line". Presentation is required on item.open and item.close, nullable on item.progress, persisted per event; the row shows the latest. label is a verb phrase for the current status ("Running tests" → "Ran tests"); the pending/completed pair is expressed by sending a new presentation on close. renderIntentSchema is generic | terminal | diff | search | read | list | web | image; it lives on command.intent, tool.intent, and DynamicTool.presentation.intent, and replaces every tool-name classifier in the UI. approvalStateSchema is "waiting" | "denied" | null.
| Member | Signature | What it does | Spec | As built |
|---|---|---|---|---|
reasoningPresentation |
(p: Omit<Presentation, "suppress">) => Presentation |
returns {...p, suppress: true}; stamp it on every reasoning open and close. The assembler forces it on close anyway (R28) |
06 §4.7; R28 | protocol/common.ts; assembler/assembler.ts hidePresentation |
7.8 Approvals, questions, interactions
interactionRequestSchema (06 §4.4; protocol/interactions.ts) is one of: {kind: "approval", subject: ApprovalSubject, reason \| null, decisions: ApprovalDecision[] (min 1)}, {kind: "question", questions: Question[] (1–4, unique ids)}, or {kind: "<pluginId>/<kind>", title (≤ 200), data: JsonValue} for a kind declared in contributes.interactionKinds. Subjects are closed and snake_case (R27):
Subject (approvalSubjectSchema) |
Fields beyond itemKey |
|---|---|
command |
command, cwd | null, actions: CommandAction[], sessionGrant: GrantProfile | null |
file_change |
writeScope | null, sessionGrant | null |
permission_grant |
toolName | null, permissions: GrantProfile |
plan |
plan, planFilePath | null |
approvalDecisionSchema is allow_once | allow_for_session | deny. grantProfileSchema is {network: {enabled: boolean | null} | null, fileSystem: {read: string[], write: string[]} | null}. commandActionSchema is read {command, name, path} | listFiles {command, path | null} | search {command, query | null, path | null} | unknown {command}. questionSchema is {id, prompt, shortLabel | null, multiSelect, options: {value, label, description | null}[] (≤ 4, unique), allowFreeText} with options empty only when allowFreeText; answerSchema is {selected: string[] (≤ 4), freeText: string | null (≤ 4096, non-blank)}. interactionResolutionSchema is {kind: "approval", decision: allow_once|allow_for_session, granted: GrantProfile | null}, {kind: "approval", decision: "deny", granted: null}, {kind: "answer", answers: Record<questionId, Answer>}, {kind: "submitted", data}, or {kind: "interrupted"}. A tool_use subject is not core; a provider declares <providerId>/tool_use as an open kind (R27).
| Member | Signature | What it does | Spec | As built |
|---|---|---|---|---|
subjectGrant |
(subject: ApprovalSubject) => GrantProfile | null |
permissions for permission_grant, null for plan, sessionGrant otherwise: the grant an allow must carry |
06 §4.4 granted rule |
protocol/interactions.ts |
resolutionMatchesRequest |
(request, resolution) => string | null |
null when the resolution fits the request; else why: wrong kind, a decision not offered, granted present/absent against subjectGrant, missing answers, open kind without submitted. interrupted always fits |
06 §8 step 4 | protocol/interactions.ts |
Flow (06 §8): core validates and dedupes, then auto-denies an approval when policy.escalation === "deny" and approvalEnforcedBy === "runtime" (actor system:auto-deny); a provider-enforcing bridge applies autoDeny() itself. Questions and open kinds are never auto-denied. Otherwise a pending_interactions row opens and the JSON-RPC id waits for interaction.resolve. Interrupt, bridge exit, or host disconnect answers outstanding ids with INTERACTION_INTERRUPTED and marks rows interrupted. allow_for_session grants persist on the thread and reach the bridge again only through providerOptions if the plugin's deriveProviderOptions opts in.
7.9 Execution options and prompt input
executionOptionsSchema (06 §7.1; protocol/common.ts): {model, reasoningLevel: string | null, serviceTier: string | null, permission: PermissionPolicy, instructions, providerOptions: Record<string, JsonValue>}. It rides every thread/* and turn/* command; the bridge reconciles and announces a rebuild with session/replaced. permissionPolicySchema is the three product presets: {mode: "accept-edits", scope: "workspace", reviewer: "user", escalation: ask|deny}, {mode: "auto", scope: "workspace", reviewer: "automatic", escalation: ask|deny}, {mode: "full", scope: "full", reviewer: null, escalation: null}; permissionModeSchema is the mode enum. Resolution order, clamps, and ResolvedExecutionOptions provenance are server-side (06 §7.2) and never cross the wire (README BK-8).
promptInputSchema (06 §4.3) is text {text, mentions: PromptMention[], visibility}, image {url, visibility}, localImage {path, visibility}, or localFile {path, name | null, sizeBytes | null, mimeType | null, visibility}. promptVisibilitySchema is "user" | "agent-only"; agent-only reaches the provider and is omitted from the user-message row; the bridge only forwards it. promptMentionSchema is {start, end, resource: {kind, label, …}} (loose; the composer owns resources).
sessionParamsSchema: {threadId, providerId, cwd, workspace: {root, writeRoots}, env, options: ExecutionOptions, tools: DynamicTool[], disallowedTools: string[], instructionMode: "append" | "replace", actions: string[], bridgeOptions}. workspace and env are filled by the host driver, never by core; env is the ambient BB_* set (BB_CLI, BB_SERVER_URL, BB_THREAD_ID, BB_PROJECT_ID, BB_ENVIRONMENT_ID, BB_THREAD_STORAGE, BB_HOST_ID, plus threads/agent-config names); pass it to agent shells through childEnv. turnParamsSchema is {threadId, providerThreadId, turnId, input: PromptInput[], clientRequestId, options, actions} as core sends the driver; turnStartParamsSchema omits turnId (no provider key exists yet) and turnSteerParamsSchema keeps it holding the provider's own key (README "Notes"; 06 §4.3). There is no expectedTurnId. threadRefSchema is {threadId, providerThreadId}; sessionIdentitySchema is {providerThreadId, sessionRestorable}. dynamicToolSchema is {name, description, inputSchema: JsonObject, presentation: {label: {pending, completed}, icon, intent, suppress}}; the bridge exposes these to the agent and answers calls through ctx.toolCall.
7.10 Recovery, errors, warnings
recoveryHintSchema (06 §4.5): restart-process (account restart), resume-fresh (session file gone), reauthenticate, reinstall, none. Send it with ctx.recovery; the providers plugin acts and records provider.warning {category: "recovery"}. errorCategorySchema (persisted, snake_case): context_window_exceeded, billing, budget_exceeded, internal, max_output_tokens, max_turns, overloaded, policy, rate_limit, sandbox, stream_disconnected, structured_output_retries, thread_rollback_failed, too_many_failed_attempts, unauthorized, unknown. A quota failure is provider.error {category: "rate_limit", settlesTurn: true} plus provider.rateLimits with resetsAt set; provider-retry keys on exactly those two. warningCategorySchema: deprecation, config, general, compaction-skipped, turn-watchdog, session-replaced, recovery. rateLimitStateSchema: {status: allowed|warning|blocked|unknown, kind: subscription-window|credits|spend-control|unknown, windows: {providerKey | null, label | null, status, resetsAtMs | null}[], resetsAt: number | null, reachedReason | null, overageStatus: allowed|warning|rejected|unavailable | null, overageReason | null}.
| Member | Signature | What it does | Spec | As built |
|---|---|---|---|---|
JsonRpcError |
class extends Error { code: number; data: JsonValue | null; toJSON() } |
the wire error; throw it from a handler to answer the request with an error | 06 §4.9 | protocol/errors.ts |
bridgeError |
(name: keyof BRIDGE_ERRORS, message, data = null) => JsonRpcError |
builds a JsonRpcError from a named code |
06 §4.9 | protocol/errors.ts |
BRIDGE_ERRORS name |
Code | Raised when |
|---|---|---|
INVALID_PARAMS |
-32602 | schema-invalid params (data.issues); unknown providerId |
METHOD_NOT_FOUND |
-32601 | unknown method; advertised method with no handler |
BRIDGE_ERROR |
-32000 | method before initialize; second initialize; handshake/narrows-only (data.rule) |
NO_ACTIVE_TURN |
-32001 | reserved for bridges: a turn-bound request with no turn |
SESSION_NOT_RESTORABLE |
-32002 | reserved for bridges: thread/resume of a lost session |
FORK_CHECKPOINT_UNSUPPORTED |
-32003 | thread/fork with sourceCheckpointId under fork !== "checkpoint" |
PROTOCOL_UNSUPPORTED |
-32004 | no common protocol version (data: {core, bridge}) |
INTERACTION_INTERRUPTED |
-32005 | every outstanding request at shutdown or EOF |
CAPABILITY_NOT_ADVERTISED |
-32006 | a gated method the bridge did not advertise |
7.11 Usage, maintenance, skills, models
Usage rides the usage delta: tokenBreakdownSchema is {totalTokens, inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens}; send total: null to let the assembler accumulate, or the provider's exact totals (06 §4.8 "one delta"). Maintenance methods are sessionless and run on a maintenance bridge instance in the host's scratch environment (06 §14.1). providerHealthSchema: {status: ready|not_installed|unauthenticated|expired|unsupported_version|unknown, statusMessage | null, accountEmail | null, planLabel | null, installedVersion | null, minimumSupportedVersion | null, canInstall, canUpdate, loginCommand | null}. providerUsageSchema: {status: "ok", accountEmail | null, planLabel | null, windows: UsageWindow[]} or {status: not_installed|unauthenticated|expired} or {status: "error", message, planLabel | null, accountEmail | null}; usageWindowSchema is {label, usedPercent, resetsAt: string | null, cost: {usedUsdCents, limitUsdCents} | null}. installationStatusSchema: {executableName, executablePath | null, installed, installSource: notInstalled|npmGlobal|external, currentVersion | null, latestVersion | null, minimumSupportedVersion | null, npmPackageName | null, npmGlobalPackageVersion | null, installAction: {kind: install|update, label: Install|Update, command} | null, needsUpdate, versionUnsupported}. installCommandSchema is {command, args: string[] (≤ 64), displayCommand}; installVerificationSchema is {kind: "installed"} | {kind: "version_changed", previousVersion} | {kind: "version_at_least", version}; core runs the command and verifies.
Skills (06 §9): injection is skills/configure {threadId, roots} with skillRootSchema {id, path, label} — staged SKILL.md directories the bridge converts to its native form. Discovery is declarative: skillScanRootSchema {scope: user|project, path, format: skill-md|claude-plugin|codex-plugin-cache, walkParents, recursive, label | null} expanded on the host (~, $CODEX_HOME, $OPENCODE_CONFIG_DIR); layouts that need code set skills.discover: true and answer skills/discover with discoveredSkillSchema {name, description, path, scope: user|project, source}. Models (06 §14.1): modelDeclSchema is {id, label, description, reasoningLevels: string[], defaultReasoningLevel | null, serviceTiers: string[], isDefault}; model/list runs only for models.catalog: "bridge", cached per (providerId, hostId, cwd) for 10 min; the provider default is the isDefault model and its defaultReasoningLevel.
7.12 Helpers and constants
| Constant | Value | Spec | As built |
|---|---|---|---|
PROTOCOL_VERSION |
3 |
06 §4.2 | protocol/handshake.ts |
SUPPORTED_PROTOCOL_RANGE |
{min: 3, max: 3} |
06 §4.2 | protocol/handshake.ts |
FORK_RANK |
{none: 0, tip: 1, checkpoint: 2} |
06 §4.2 narrows-only | protocol/handshake.ts |
GATED_METHODS |
model/list, provider/health, provider/usage, provider/installation/status, provider/installation/run, skills/discover, thread/resume, thread/fork, thread/name/set, thread/archive, thread/unarchive, thread/goal/clear |
06 §4.3 gate column | declaration.ts (keys of METHOD_GATES) |
SCOPED_DELTA_KINDS |
item.open, item.progress, item.close, todo, usage, provider.error, provider.warning, unhandled (derived from the scoped() members of deltaSchema) |
06 §4.6 | protocol/deltas.ts |
ITEM_KEY_SEPARATOR |
"\u001f" (U+001F) |
06 §4.6 | protocol/common.ts |
EXTENSION_KIND_PATTERN |
/^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-zA-Z0-9_-]*$/ (<pluginId>/<kind>) |
06 §4.4, §4.7 | protocol/interactions.ts |
APPROVAL_SUBJECT_KINDS |
["command", "file_change", "permission_grant", "plan"] |
06 §4.4; R27 | protocol/interactions.ts |
BRIDGE_ERRORS |
the nine codes of §7.10 | 06 §4.9 | protocol/errors.ts |
| Member | Signature | What it does | Spec | As built |
|---|---|---|---|---|
coreToBridgeRequests |
Record<method, {params, result}> |
the zod table of §7.3; CoreToBridgeMethod is its key type |
06 §4.3 | protocol/requests.ts |
coreToBridgeSpecs |
CoreToBridgeSpecs (the same table typed per method) |
lets code generic over M read one method's schemas without a cast; the runtime parses params and results through it |
06 §4.3 | protocol/requests.ts |
bridgeToCoreRequests |
{"tool/call": {params, result}, "interaction/request": {params, result}} |
the bridge → core table; ctx.toolCall/ctx.requestInteraction parse results through it |
06 §4.4 | protocol/requests.ts |
bridgeNotifications |
Record<method, schema> |
the seven notification schemas of §7.3 | 06 §4.5 | protocol/notifications.ts |
jsonValueSchema, jsonObjectSchema |
kernel-core's JSON schemas | re-exported beside the wire schemas (M1) | — | protocol/index.ts |
The assembler (assembler/assembler.ts; 06 §5) runs on the host, one instance per (bridge process, thread), and the bridge never sees it: the driver feeds it thread/delta and it emits section 04 events synchronously, one delta → one event, with no timers. It binds turn keys FIFO to core-minted turnIds, mints itemIds as <entropy>-<serial>, copies key.providerItemId into payload.providerItemId, validates extension kinds against their JSON schemas (createExtensionKinds), settles zero-work turns, routes turnless deltas to provider.unhandled, and buffers unknown-key output up to 256 KiB. Its rule ids (ASSEMBLER_RULE_IDS, 06 §5.2): ids/minted-centrally, turn/bound-to-requested, turn/opens-only-explicitly, turn/accepted-input-queues, turn/zero-work-settles, item/opens-before-delta, item/close-shape-wins, item/settles-once, item/scope-fixed-at-open, message/auto-detach, stateless/one-delta-one-event, usage/accumulate, turnless/unhandled, settle/error, session/reset-flushes, fallback/dedup. The conformance kit (06 §10; 33 rule ids) and record mode (06 §11) live in @bb/bridge-kit/conformance; the spec's @get-bb/plugin-sdk/bridge/conformance subpath is not in the SDK's package.json#exports as built.
7.13 Schema index
| Schema | Validates |
|---|---|
Handshake (protocol/handshake.ts) |
|
protocolRangeSchema |
{min, max} positive ints, min ≤ max |
initializeParamsSchema |
{protocol, client: {name: "bb", version}, plugin: {id, version}, providerIds} (loose) |
bridgeCapabilitiesSchema |
the seven capability facts of §7.4 (loose) |
initializeResultSchema |
{protocol: int, capabilities} (loose) |
forkCapabilitySchema |
"none" | "tip" | "checkpoint" |
Declaration (declaration.ts) |
|
providerDeclarationSchema |
the full ProviderDeclaration of §7.2 |
reasoningLevelDeclSchema |
{id, label, rank: int, description} |
serviceTierDeclSchema |
{id, label, description} |
composerActionDeclSchema |
{id, trigger, label, description, sticky} |
skillScanRootSchema |
{scope, path, format, walkParents, recursive, label | null} |
Common (protocol/common.ts) |
|
itemKeySchema |
{providerItemId, channel, parentRef}, ≥ 1 non-null, no U+001F (strict) |
itemStatusSchema |
pending | completed | failed | interrupted |
renderIntentSchema |
generic | terminal | diff | search | read | list | web | image |
approvalStateSchema |
"waiting" | "denied" | null |
presentationSchema |
{label, icon, title?, detail?, suppress?, tint?} (strict) |
permissionModeSchema |
accept-edits | auto | full |
permissionPolicySchema |
the three presets keyed on mode |
executionOptionsSchema |
{model, reasoningLevel, serviceTier, permission, instructions, providerOptions} |
promptMentionSchema |
{start, end, resource: {kind, label}} (loose resource) |
promptVisibilitySchema |
"user" | "agent-only" |
promptInputSchema |
text | image | localImage | localFile parts, each with visibility |
dynamicToolSchema |
{name, description, inputSchema, presentation: {label: {pending, completed}, icon, intent, suppress}} |
modelDeclSchema |
{id, label, description, reasoningLevels, defaultReasoningLevel, serviceTiers, isDefault} |
Deltas (protocol/deltas.ts) |
|
scopeSchema |
{scope: "turn", turnKey} | {scope: "thread"} (strict) |
deltaSchema |
the 21-kind union of §7.5 (strict; text.delta.part refined against channel) |
threadDeltaParamsSchema |
{threadId (min 1), deltas: Delta[]} (strict; protocol/notifications.ts) |
todoItemSchema |
{id, text, status: pending | active | completed | failed} |
tokenBreakdownSchema |
the five token counters |
rateLimitStateSchema |
RateLimitState of §7.10 |
errorCategorySchema |
the 16 snake_case categories |
warningCategorySchema |
the 7 warning categories |
diffFileSchema |
{path, kind: add | update | delete, added: int, removed: int, diff | null} |
textChannelSchema |
message | reasoning | reasoningSummary | plan |
Items (protocol/items.ts) |
|
fileChangeSchema |
{path, kind, movePath, diff, oldText, newText} (strict) |
taskPayloadSchema |
TaskPayload of §7.6 (strict) |
coreItemSchema |
the 11 core {kind, payload} shapes (strict) |
extensionItemSchema |
{kind: <pluginId>/<kind>, payload: JsonObject} with no providerItemId |
itemSchema |
coreItemSchema | extensionItemSchema |
Interactions (protocol/interactions.ts) |
|
grantProfileSchema |
{network: {enabled} | null, fileSystem: {read, write} | null} |
commandActionSchema |
read | listFiles | search | unknown classification |
approvalSubjectKindSchema |
enum over APPROVAL_SUBJECT_KINDS |
approvalSubjectSchema |
the four subjects of §7.8 |
approvalDecisionSchema |
allow_once | allow_for_session | deny |
questionSchema |
one question; ≤ 4 unique options; empty only with allowFreeText |
answerSchema |
{selected (≤ 4), freeText | null (≤ 4096, non-blank)} |
extensionKindNameSchema |
`${string}/${string}` matching EXTENSION_KIND_PATTERN |
interactionRequestSchema |
approval | question | <pluginId>/<kind> requests |
interactionResolutionSchema |
approval (allow | deny) | answer | submitted | interrupted |
Requests (protocol/requests.ts) |
|
threadRefSchema |
{threadId, providerThreadId} (loose) |
sessionParamsSchema |
SessionParams of §7.9 (loose) |
turnParamsSchema |
TurnParams with the core turnId (driver-facing) |
turnStartParamsSchema |
TurnParams without turnId (what turn/start receives) |
turnSteerParamsSchema |
TurnParams with turnId = the provider's turn key (what turn/steer receives) |
sessionIdentitySchema |
{providerThreadId, sessionRestorable} |
toolContentSchema |
{type: "text", text} | {type: "image", imageUrl} |
Maintenance and skills (protocol/maintenance.ts) |
|
providerHealthSchema |
ProviderHealth of §7.11 |
usageWindowSchema |
{label, usedPercent, resetsAt | null, cost | null} |
providerUsageSchema |
ok | not_installed | unauthenticated | expired | error usage |
installationStatusSchema |
InstallationStatus of §7.11 |
installCommandSchema |
{command, args (≤ 64), displayCommand} |
installVerificationSchema |
installed | version_changed | version_at_least |
skillRootSchema |
{id, path, label} |
discoveredSkillSchema |
{name, description, path, scope, source} |
recoveryHintSchema |
the five recovery hints |
JSON (protocol/index.ts, from kernel-core) |
|
jsonValueSchema |
any JSON value |
jsonObjectSchema |
a JSON object |
7.14 Differences from the old tree's docs/provider-plugin-api.md
| Topic | Old target doc | Built (next/) |
|---|---|---|
| Registration | bb.providers.register({...}) in server code, with deriveProviderOptions inline and env.passthrough |
manifest contributes.providers[] (inlined from src/declaration.ts) plus ctx.provide(providerHooks, …, providerFacts(decl)); env.passthrough deleted, bridgeEnv hook and childEnv() instead |
| Bridge entry | defineProviderBridge({handleLine, start, onClose}) |
defineBridge({declarations, initialize, methods}) under defineHostEntry role provider-bridge; runBridge drives stdio |
| Handshake | grammarVersions: [min, max] reported per session; steerMode: "inject" | "queue" |
initialize {protocol: {min, max}} negotiated once per process; capabilities add threadGoal, fork; steer: "native" | "queue"; narrows-only check |
| Item vocabulary | 13 camelCase kinds (fileChange, fileRead, search, delegation, planSteps, …) |
12 snake_case core kinds; task {role} replaces delegation; todo fact replaces planSteps; fileRead/search are tool with intent |
| Presentation | label: {pending, completed}, icon: {glyph} | {asset}, tint: {light, dark} |
label: string, icon: string, tint enum; a new presentation on close; suppress: true forced on reasoning |
| Recovery | {kind: sessionArchived | authRequired | restartRecommended | staleTurn | rateLimited, message, retryable} |
provider/recovery {threadId | null, hint: restart-process | resume-fresh | reauthenticate | reinstall | none, reason, retryable} |
| Interactions | approvals command · fileChange · toolUse · permissionGrant; requests userQuestion, planReview |
closed command, file_change, permission_grant, plan; question; open <pluginId>/<kind>; tool_use only as a declared open kind; requestKey dedupe |
| Execution options | {model, serviceTier?, reasoningLevel, promptMode?, instructions, providerOptions} & PermissionPolicy |
{model, reasoningLevel | null, serviceTier | null, permission, instructions, providerOptions}; no promptMode; composer actions ride turn/start.actions |
| Request and state lanes | item/tool/call; thread state usage, contextWindow, rateLimits, modelFallback as snapshots; skills/scanRoots {cwd} |
tool/call; usage {last, total, contextWindow}, provider.rateLimits, provider.modelFallback deltas with explicit scope; declarative skills.scanRoots plus gated skills/discover |
| Testing kit | @get-bb/plugin-sdk/provider-bridge/testing, /acp |
conformance kit and fixture bridge in @bb/bridge-kit/conformance; no ACP kit subpath |