bb · provider plugins

API reference

The public surface of @get-bb/plugin-sdk 0.4.16 that a provider plugin uses, by subpath. Names are given exactly as exported; a member that still carries experimental_ is listed that way. Types are listed by name; their JSDoc in the published .d.ts bundle is the contract.

Subpath map #

ImportUsed fromWhat it holds
@get-bb/plugin-sdkserver.ts, contract.tsBbPluginApi and the declaration types; defineRpcContract.
@get-bb/plugin-sdk/hosthost.tsexperimental_defineHostEntry, the native-roots contract, the vendor-plugin root readers.
@get-bb/plugin-sdk/provider-bridgethe bridgeThe bridge entry contract, the protocol schemas and grammar, the bridge kit, the domain vocabulary. Real code, bundled into the artifact.
@get-bb/plugin-sdk/provider-bridge/acphost.ts, server.tsThe generic ACP bridge, the agent probe, the launch-spec schema.
@get-bb/plugin-sdk/provider-bridge/testingtestsThe conformance kit, the real delta assembler, the JSON-RPC harness, recorded replay and parity.
@get-bb/plugin-sdk/ai-serviceshost.tsThe AI-services host contract and its schemas.
@get-bb/plugin-sdk/appapp.tsxThe provider directory hook, the timeline renderer and provider icon slots, the pickers.
@get-bb/plugin-sdk/testing, /testing/hosttestsThe fake plugin host, the public-SDK-only scan; the host-entry harness.

@get-bb/plugin-sdk (server side) #

bb.providers.register(declaration): { dispose(): void }

Register an agent provider this plugin contributes. The declaration is validated at call time; the provider joins the registry when the plugin load commits and appears in listings as exactly one client shape, ProviderInfo. Ids are flat and collision-rejected (first live registration wins; a later one from another plugin fails that plugin's load). A plugin may register several providers and may re-register after dispose(); registrations are replaced wholesale on reload. Declaring a provider without a bb.host entry fails the load.

const registration = bb.providers.register({ id: "echo-agent", displayName: "Echo", /* … */ });
// later, for a settings-driven re-declaration:
registration.dispose();
bb.providers.register(nextDeclaration);

bb.experimental_aiServices.register({ id, displayName, kinds }): { dispose(): void } experimental

Register an AI service served from the plugin's host entry; kinds is a list of "inference" | "voice". Throws on an id another live plugin serves; refuses reserved ids; requires a bb.host entry. A declared entry that failed to build is not refused at the call: the service is staged unbound, carrying the build problem, the load fails on that problem after the factory runs, the same plugin's providers are retained as unavailable with it, and the unbound service is neither listed nor callable.

bb.agents.registerTool({ name, description, parameters, presentation?, instructions?, execute })

A native dynamic tool. presentation?: { label?: { pending, completed }, icon?: { glyph }, suppress?, tint? } says how calls read as a timeline row; the server fills what you leave out (a generic Running <name> / Ran <name> label, the plugin's bb.branding.icon when it is a host glyph name, then Toolbox) and hands one complete presentation to the bridge with the tool definition. Labels are capped at 80 characters. icon.glyph is a host glyph ("Workflow") or one of this plugin's own declared icons as "<pluginId>/<name>" (below); a namespaced glyph that names another plugin or an undeclared name rejects the tool registration.

defineRpcContract({ method: { input, output } })

Builds the Standard Schema contract shared by a plugin's server and host entries. Spread published contracts into your own to serve several at once:

import { defineRpcContract } from "@get-bb/plugin-sdk";
import { experimental_aiServicesHostContract } from "@get-bb/plugin-sdk/ai-services";
import { experimental_nativeRootsHostContract } from "@get-bb/plugin-sdk/host";

export const hostContract = defineRpcContract({
  ...experimental_aiServicesHostContract,
  ...experimental_nativeRootsHostContract,
  probeAgent: { input: z.object({ command: z.string() }).strict(), output: probeSchema },
});

bb.hosts.experimental_client({ contract, experimental_signals? }) experimental

A typed client for the plugin's own host entry: call(method, input, { hostId, signal? }), experimental_onWorkerExit(handler), experimental_onSignal(name, handler). Call methods only after registration completes (from a handler, background service or timer), never at factory time. The ACP and Pi plugins use it to probe agents and read settings on each connected host.

Types

PluginProviderDeclaration, PluginProviderCapabilities, PluginProviderStrings, PluginProviderOptionDescriptor, PluginProviderExtensionKindDeclaration, PluginProviderOptionsContext, PluginProviderFallbackModel, PluginProviderMaintenance, PluginProviderNativeRoots, PluginProviderNativeRootEntry, PluginProviderPermissionMode, PluginProviderReasoningLevel, PluginProviderComposerAction, PluginProviderModelCatalogScope, PluginProviders, PluginAiServiceDeclaration, PluginAiServiceKind, PluginAiServices, PluginAgentToolPresentation, PluginAgentToolLabels, ProviderInfo.

Declaration field table #

FieldTypeRequiredNotes
idstringyes2–64 chars, [a-z0-9-], starts with a letter or digit. Permanent.
displayNamestringyes1–80 chars, non-blank.
familystringnoSame grammar as id; grouping only.
iconstringnoThree forms: a host glyph name ("Zap"); a ./-relative SVG path (no leading /, no .., no backslashes); or one of this plugin's declared icons as "<pluginId>/<name>" (the plugin id must be yours and the name in bb.branding.experimental_icons, else the load fails). A path-looking value that is none of these is rejected by name. See Plugin icons.
stringsPluginProviderStringsnosignInHint, expiredHint, installUrl required inside; brandPrefix?, planModeCopy?, iconTint? { light, dark }.
maintenance{ health?, usage?, installation? }noEach boolean defaults to false.
capabilitiesPluginProviderCapabilitiesyessupportsServiceTier, supportsNativeUserQuestion, fork: "none"|"tip"|"checkpoint", supportsManualCompaction, supportsThreadArchive, supportsThreadRename, permissionModes[] (non-empty, unique), reasoningLevels[] (non-empty, unique).
composerActions("plan" | "goal")[]yesMay be empty; no duplicates.
reasoningLevels{ id, label, description? }[]noNon-empty when present, unique ids. Labels for the coarse ladder are generated when omitted.
serviceTiers{ id, label, description? }[]noNon-empty when present, unique ids.
extensionKindsRecord<name, { item?, state? }>noLocal names [a-z0-9-]+; Standard Schema validators. A kind is accepted at ingest only on a thread whose provider this plugin registered; a foreign kind persists as provider/unhandled.
models.fallbackPluginProviderFallbackModel[]noUnique ids; exactly one isDefault. Each: id, displayName, description, supportedReasoningEfforts[] (non-empty), defaultReasoningEffort (one of them).
models.scope"host" | "workspace"noDefault "workspace".
env.passthroughstring[]no[A-Z_][A-Z0-9_]*, at most 32.
deriveProviderOptions(ctx) => Record<string, JsonValue>noSynchronous, fast; called on every session and turn command. ctx: { threadId, projectId, model, permissionMode, promptMode?, settings }.
experimental_nativeSkillRoots{ user?, project? }noRelative to the host home (user) or the workspace (project), no dot segments; entries: path or { path, recursive?, ancestors?, namePrefix?, skipIfManifest? }; ≤ 32 per side. An absolute side is refused: a host-absolute directory is the resolver's answer. experimental
experimental_nativeCommandRootssame shapenoFlat directories of *.md prompt files. experimental
experimental_resolvesNativeRootsbooleannoThe host entry implements resolveNativeRoots. experimental
experimental_bridgeOptionsRecord<string, JsonValue>noPlain JSON ≤ 64 KiB, frozen at registration, forwarded on every bridge request, part of the process identity. experimental
experimental_visibility"always" | "installed"noDefault "always". "installed" requires maintenance.health. experimental

What clients see. ProviderInfo is { id, pluginId, displayName, family?, icon?: { glyph }, logoUrl: string | null, maintenance: { health, usage, installation }, capabilities, composerActions, available, strings?, serviceTiers?, reasoningLevels?, extensionKinds? }. A path-shaped or declared-icon icon arrives as logoUrl (/api/v1/system/providers/<id>/logo, bytes snapshotted at registration) with no icon.glyph; a host glyph name arrives as icon.glyph with a null logoUrl. maintenance is the only maintenance shape on the row: the experimental_providerHealth, experimental_providerUsage and experimental_providerInstallation booleans 0.4.15 served are gone with no alias, so a typed read of them no longer compiles and the served row has no such key (see Compatibility).

Plugin icons: manifest, route, glyph forms experimental #

The plugin's own icon vocabulary, declared in the manifest and referenced by name. The semantics (ingest rule, resolution order, fallback, the validator's reasons) are in Concepts; this is the shape.

Manifest field

FieldTypeRequiredNotes
bb.branding.experimental_iconsRecord<name, "./path.svg">noName: [a-z0-9][a-z0-9-]*, at most 48 characters; at most 64 entries. Value: a plugin-relative path starting with ./ and ending in .svg (case-insensitive), a regular file inside the plugin directory (symlinks resolved), at most 32 KiB, passing the declared-icon SVG validator. Checked by bb plugin build and at load; a failure names the entry (bb.branding.experimental_icons["name"]) and fails the build or the load.
bb.branding.iconstringat least one of icon / logo.lightA host glyph name or a ./ SVG path only. The namespaced form "<pluginId>/<name>" is refused by the manifest schema with the value named.

Where a namespaced glyph is accepted

SiteFormsChecked
Row presentation.icon.glyph (bridge, item.open / item.close)host glyph "FileText"; declared icon "<pluginId>/<name>"At ingest, against the thread's provider plugin (a server: "bb" tool row: against the plugin that registered the tool). A miss persists the event as provider/unhandled with rawType: "presentation/icon:<itemType>" and { itemId, itemType, glyph, reason }.
bb.agents.registerTool({ presentation: { icon } })host glyph; declared iconAt the register call: a foreign plugin id or an undeclared name rejects the registration (tool "<name>" presentation.icon "…" is not an icon declared by plugin "…").
bb.providers.register({ icon })host glyph; ./ SVG path; declared iconAt the register call: a foreign plugin id or an undeclared name fails the plugin load (provider "<id>" icon "…" is not an icon declared by plugin "…"). A declared icon projects to logoUrl like a path.
bb.branding.iconhost glyph; ./ SVG pathThe namespaced form fails the build and the load.

Grammar: a namespaced glyph matches ^[a-z0-9-]+/[a-z0-9][a-z0-9-]*$, the same split as an extension kind; host glyph names are PascalCase without a /, so the two cannot collide. The helpers that implement the grammar (isNamespacedGlyph, parseNamespacedGlyph, the PLUGIN_ICON_* caps) live in bb's domain package and are not exported by any @get-bb/plugin-sdk subpath; build the string yourself, as the echo does with `${ECHO_PLUGIN_ID}/receipt`.

The icon route

URLGET /api/v1/plugins/<pluginId>/assets/icons/<name>.svg?h=<hash>, where hash is the first 16 hex characters of the SHA-256 of the served bytes. The exact URL is published per plugin as icons[name] on the installed-plugin inventory (GET /api/v1/plugins, bb.sdk.plugins.list(); a server older than the field reads as a plugin that declares none).
200 headerscontent-type: image/svg+xml; x-content-type-options: nosniff; content-security-policy: default-src 'none'; style-src 'unsafe-inline'; cache-control: public, max-age=31536000, immutable when h equals the current hash, else no-store.
404 { ok: false, error: "plugin has no such icon" }The file segment does not end in .svg or is only .svg; the plugin is not installed (uninstalled plugins included); or the name is not in the plugin's map. A disabled plugin's icons are still served.
BytesThe exact validated file, snapshotted at load and re-validated when snapshotted; never rewritten. A file edited between two reads that no longer validates fails the load.

Conformance

Rule presentation/icon-namespaced-declared ("every namespaced presentation glyph names one of the plugin's declared icons"), enabled by the session fixture's icons: { pluginId, names }; no result when the field is omitted, skipped when no item carried a presentation, and a server: "bb" tool row is exempt. See Testing.

@get-bb/plugin-sdk/host #

ExportSignaturePurpose
experimental_defineHostEntry({ contract, handlers, experimental_signals?, dispose? }) => ExperimentalHostEntryDefine the single host executable exported by bb.host. Handlers receive (input, context); context has signal, lifecycle.signal, experimental_paths { dataDir, tempDir }, experimental_emitSignal, experimental_watch, experimental_retainWorker.
experimental_nativeRootsHostContract{ resolveNativeRoots: { input, output } }The contract a provider plugin serves when its declaration sets experimental_resolvesNativeRoots. Spread it into your own contract.
experimental_nativeRootsResolveInputSchemaz.object({ providerId, cwd: string | null })The input: which provider is being listed, and the workspace or null.
experimental_nativeRootsResolveOutputSchema{ skills?, commands? } → normalized rootsFills per-root defaults (recursive: false, ancestors: false, namePrefix: "", shape: "skills" | "commands") and cuts each side to 256.
experimental_filterResolvedNativeRoots(answer, { warn, warned? }) => { answer, dropped: [{ side, path, reason }], truncated: { skills, commands } }Judge each root on its own; drop a refused one with a warning (resolveNativeRoots: dropped the skills root "…": …, once per worker per side, path and reason); cut each side to the cap of 256 with one warning. reason lists the field-level issues (path: …; origin: …) when any field is malformed; the cross-field rules (ancestors on a user root, a manifest marker on a non-skills shape, a fallback name on a non-skill-file shape) are judged only on a root whose fields all parse. Call it before you return.
experimental_resolveClaudePluginRoots experimental({ cwd: string | null, homeDir, env }) => Promise<{ skills, commands, claudeDir }>Every enabled Claude Code plugin on this host, for one workspace: installs from <claudeDir>/plugins/installed_plugins.json (a managed or user install is a user root; project and local installs count only for the workspace that holds them, as project roots; an install whose directory is gone is read from the plugin cache), enablement from the user, project and local enabledPlugins settings with the manifest's defaultEnabled for an unlisted plugin, plus plugins dropped into the project and user skills directories. Each is walked in the claude layout and prefixed <plugin>:. claudeDir is CLAUDE_CONFIG_DIR (absolute, ~-relative or home-relative) or ~/.claude, so a resolver that also lists that directory's own skills and commands agrees with the reader. Only CLAUDE_CONFIG_DIR is read from env.
experimental_resolveVendorPluginRoots experimental({ plugins: ExperimentalVendorPlugin[], layout: "claude" | "grok" }) => Promise<{ skills, commands }>The skill and command roots of the given plugin directories, in the given order, each prefixed with its plugin's name. claude: the root SKILL.md (named after the plugin when its frontmatter names none), skills/, commands/, then the manifest's skills and commands entries, each directory read flat. grok: the manifest's entries only, each directory recursive. A missing component is skipped; an absolute or escaping manifest entry is ignored; a path answered twice is kept for the first plugin that named it. Symlinks follow the plugin's origin: a user plugin's skill components are followed, a project plugin's are not, and command components never are. A plugin is { rootPath, name, origin: "user" | "project", skills?, commands? }.
import os from "node:os";
import { experimental_defineHostEntry, experimental_nativeRootsHostContract,
         experimental_filterResolvedNativeRoots } from "@get-bb/plugin-sdk/host";

export default experimental_defineHostEntry({
  contract: experimental_nativeRootsHostContract,
  handlers: {
    resolveNativeRoots: ({ providerId, cwd }) => {
      const skills = [{ path: `${os.homedir()}/.myagent/skills`, origin: "user", shape: "skills" }];
      return experimental_filterResolvedNativeRoots({ skills }, { warn: console.warn }).answer;
    },
  },
});

A resolver that lists the vendor plugins installed on the host reads them with the kit and answers the directory's own roots beside them, so both agree on where the config directory is:

import os from "node:os";
import { experimental_filterResolvedNativeRoots, experimental_resolveClaudePluginRoots }
  from "@get-bb/plugin-sdk/host";

resolveNativeRoots: async ({ cwd }) => {
  const plugins = await experimental_resolveClaudePluginRoots({ cwd, homeDir: os.homedir(), env: process.env });
  return experimental_filterResolvedNativeRoots({
    skills: [{ path: `${plugins.claudeDir}/skills`, origin: "user", shape: "skills" }, ...plugins.skills],
    commands: [{ path: `${plugins.claudeDir}/commands`, origin: "user", shape: "commands" }, ...plugins.commands],
  }, { warn: console.warn }).answer;
},

An answer lists each path once per side: the readers keep the first root per path in answer order, and bb scans each absolute path once per provider across the declared and resolved roots (declared skills, project then user; declared commands; then the resolved skills and commands, each in the order given), so a resolved root that repeats a declared one is listed under the declared root's identity.

Types: ExperimentalHostEntry, ExperimentalHostPaths, ExperimentalHostRpcContext, ExperimentalHostRpcHandlers, ExperimentalHostSignalContract, ExperimentalHostSignals, ExperimentalHostWatch*, ExperimentalHostWorkerLease, ExperimentalNativeRootsHostContract, ExperimentalNativeRootsResolveInput, ExperimentalNativeRootsResolveOutput, ExperimentalNativeRootsResolveAnswer (the input form a handler writes), ExperimentalDroppedNativeRoot, ExperimentalFilteredNativeRoots, ExperimentalVendorPlugin, ExperimentalVendorPluginRoots, ExperimentalVendorPluginRootsArgs, ExperimentalClaudePluginRoots, ExperimentalClaudePluginRootsArgs. (ExperimentalResolvedNativeRoot is gone; a resolved root is ExperimentalNativeRootsResolveOutput["skills"][number].)

@get-bb/plugin-sdk/provider-bridge #

Curated by hand, named exports only. Grouped the way a bridge consumes it.

1. The bridge entry contract

ExportSignaturePurpose
experimental_defineProviderBridge(definition: ProviderBridgeDefinition) => ProviderBridgeEntryWrap { handleLine, start?, onClose?, onSigterm?, onSigint? } with experimental_apiVersion: 1. Export the result as experimental_providerBridge.
PROVIDER_BRIDGE_EXPORT_NAME"experimental_providerBridge"The export name the bootstrap looks for.
typesProviderBridgeContext { pluginId, dataDir, tempDir }, ProviderBridgeDefinition, ProviderBridgeEntry

2. Protocol constants and schemas

ExportValue / shapePurpose
PROVIDER_BRIDGE_PROTOCOL_VERSION2Answer it in initialize.
THREAD_DELTA_GRAMMAR_V2, THREAD_DELTA_GRAMMAR_V32, 3Report grammarVersions: [V3, V3].
THREAD_DELTA_NOTIFICATION_METHOD"thread/delta"The one timeline lane.
BRIDGE_REQUEST_METHODSrecordRuntime → bridge method names (table below). Key your dispatch on it.
BRIDGE_NOTIFICATION_METHODSthreadIdentity, sessionReplaced, providerRaw, providerRecovery, errorBridge → runtime notification names.
BRIDGE_INBOUND_REQUEST_METHODStoolCall: "item/tool/call", interactionRequest: "interaction/request"Bridge → runtime request names.
BRIDGE_JSON_RPC_ERRORSrecordError codes (table below).
bridgeCapabilitiesSchema, bridgeGrammarVersionsSchema, bridgeSteerModeSchemazodThe handshake result's capability block and its parts.
initializeParamsSchema, modelListParamsSchema, threadStartParamsSchema, threadResumeParamsSchema, threadForkParamsSchema, threadStopParamsSchema, threadDiscardParamsSchema, threadArchiveParamsSchema, threadUnarchiveParamsSchema, threadNameSetParamsSchema, threadGoalClearParamsSchema, turnStartParamsSchema, turnSteerParamsSchema, skillsConfigureParamsSchemazodParams of each runtime request. Parse with safeParse and answer INVALID_PARAMS with the issues on failure.
providerMaintenanceParamsSchema, providerHealthSchema, providerHealthResultSchema, providerUsageSchema, providerUsageWindowSchema, providerUsageResultSchema, providerInstallationStatusParamsSchema, providerInstallationStatusSchema, providerInstallationRequirementSchema, providerInstallationActionSchema, providerInstallationActionKindSchema, providerInstallationSourceSchema, providerInstallationRunParamsSchema, providerInstallationRunResultSchema, providerInstallationCommandSchema, providerInstallationVerificationSchemazodThe maintenance surface.
threadDeltaSchema, threadDeltaNotificationParamsSchemazodThe grammar: one delta; { threadId, deltas }.
deltaItemShapeSchema, deltaItemKeySchema, deltaPresentationSchema, deltaFileChangeSchema, deltaFileReadShapeSchema, deltaSearchShapeSchema, deltaDelegationShapeSchema, deltaPlanStepsShapeSchema, deltaExtensionShapeSchema, deltaBackgroundTaskShapeSchema, deltaProgressSnapshotSchema, deltaTextChannelSchema, deltaOutputChannelSchema, deltaNoTurnFallbackSchemazodThe item shapes, keys and channels.
providerRecoveryHintSchema, providerRecoveryNotificationSchema, bridgeErrorDataSchemazodRecovery hints and the error.data carrier.

Types: BridgeCapabilities, BridgeExecutionOptions, BridgeGrammarVersions, BridgeSteerMode, InitializeResult, ThreadDelta, ThreadDeltaKind, ThreadDeltaNotificationParams, DeltaItemShape, DeltaItemShapeType, DeltaItemKey, DeltaPresentation, DeltaFileChange, DeltaFileReadShape, DeltaSearchShape, DeltaDelegationShape, DeltaPlanStepsShape, DeltaExtensionShape, DeltaBackgroundTaskShape, DeltaProgressSnapshot, DeltaTextChannel, DeltaOutputChannel, DeltaNoTurnFallback, ProviderRecoveryHint, ProviderRecoveryNotification, BridgeErrorData, ProviderHealth, ProviderHealthResult, ProviderUsage, ProviderUsageWindow, ProviderUsageResult, ProviderMaintenanceParams, ProviderInstallation*.

Bridge methods (runtime → bridge) #

MethodParamsResultGated by
initialize{ protocolVersion, client: { name, version }, grammarVersions }{ protocolVersion, capabilities }always, first
model/list{ cwd? } (cwd sent for scope: "workspace"){ models: AvailableModel[], selectedOnlyModels? }always
thread/start{ threadId, cwd, options, dynamicTools?, disallowedTools?, instructionMode, input? }{ providerThreadId, sessionRestorable? }always
thread/resumesession fields + providerThreadIdsamealways
thread/forksession fields + sourceProviderThreadId, sourceProviderCheckpointId?samehandshake fork"none"; a "tip" bridge rejects a checkpoint with FORK_CHECKPOINT_UNSUPPORTED
thread/stop{ threadId, providerThreadId, intent: "interrupt" | "release", activeTurnId }{}always
thread/discard{ threadId, providerThreadId }{}always
thread/archive, thread/unarchive{ threadId, providerThreadId }{}threadArchive
thread/name/set{ threadId, providerThreadId, title }{}threadRename
thread/goal/clear{ threadId, providerThreadId }{}threadGoalClear
turn/start{ threadId, providerThreadId, input, clientRequestId, options }{}always
turn/steerturn fields + expectedTurnId{} or NO_ACTIVE_TURNalways; delivery per steerMode
skills/configure{ roots: [{ id, path, skills: [{ name, description }] }] }{}skills.configure; once per process
provider/health{ providerId, cwd?, providerOptions? }{ supported: false } | { supported: true, health }declaration maintenance.health
provider/usagesame{ supported: false } | { supported: true, usage }maintenance.usage
provider/installation/statussame + requirement?: "thread_rewind"ProviderInstallationStatusmaintenance.installation
provider/installation/runsame + action: "install" | "update"{ available: false, message } | { available: true, command, verification }maintenance.installation

Session fields on start/resume/fork: threadId, cwd, options (BridgeExecutionOptions), dynamicTools? (each with its resolved presentation), disallowedTools?, instructionMode. Execution options: { model?, serviceTier?, reasoningLevel?, promptMode?, instructions?, envVars?, providerOptions? } intersected with the runtime permission policy (permissionMode, permissionScope, approvalReviewer, permissionEscalation).

Notifications and bridge requests (bridge → runtime) #

MessageKindParamsPurpose
thread/deltanotification{ threadId, deltas: ThreadDelta[] }The timeline lane. Batch deltas per thread.
thread/identitynotification{ threadId, providerThreadId, sessionRestorable? }Announce a session's provider handle before any delta for it.
session/replacednotification{ threadId, providerThreadId: string | null, reason, contextLost }Mandatory whenever a live session is torn down and rebuilt.
provider/recoverynotification{ threadId?, kind, message, retryable }An unsolicited recovery hint (no request to reject).
provider/rawnotification{ threadId?, coverage: "noise" | "unknown", payload }Droppable diagnostics; may carry no bb ids.
errornotification{ threadId?, message }A bridge-level error message.
item/tool/callrequest{ providerThreadId, threadId?, turnId: string | null, callId, tool, arguments, providerNativeIds? }Call a bb-injected tool. Result: { success, contentItems: [{ type: "inputText", text } | { type: "inputImage", imageUrl }] }.
interaction/requestrequest{ providerThreadId, threadId?, turnId, payload, providerNativeIds? }Raise an approval, a user question, or a plugin-defined request. Result: the canonical resolution.

Error codes #

NameCodeWhen
INVALID_PARAMS-32602Params failed schema validation; carry the issues.
METHOD_NOT_FOUND-32601Method not implemented by this bridge.
BRIDGE_ERROR-32000Generic bridge failure.
NO_ACTIVE_TURN-32001A turn/steer arrived but the session has no active turn.
SESSION_NOT_RESTORABLE-32002thread/resume for a session the provider can no longer restore.
FORK_CHECKPOINT_UNSUPPORTED-32003thread/fork with a checkpoint on a tip-only bridge.

Any error response may carry data: { recovery: { kind, message, retryable } } (bridgeErrorDataSchema).

3. The bridge kit #

ExportSignaturePurpose
createBridgeIo({ write? }) => { send, sendResult, sendError }The stdout writer: sendError(id, code, message, data?) omits data when undefined.
createBridgeLineHandler({ handleParsedMessage }) => (line) => voidTrim, skip blanks, JSON.parse, ignore non-JSON.
runBridgeRequest({ request, handleRequest, sendError }) => voidRun an async handler; a thrown experimental_BridgeRecoveryError becomes error.data.recovery, any other throw a -32000.
experimental_BridgeRecoveryError experimentalnew ({ code, message, recovery, cause? })Reject a request with a typed hint.
createPendingToolCallTracker({ sendToolCall }) => { forwardToolCall, handleToolCallResponse, resolvePendingToolCalls }Mint item/tool/call requests and match replies; never rejects; error-resolve a session's calls by scope.
decodeToolCallResponsePayload(result) => { content, contentBlocks, images, isError }Decode a tool-call result into text and image blocks.
experimental_buildBridgeToolCallContent experimental(result) => BridgeToolCallContent[]Render a decoded payload as MCP-style result blocks.
decodeBridgeJsonRpcResponse(input) => BridgeJsonRpcResponse | nullNarrow a line to a response (never a request) by the absence of method.
bridgeRequestEnvelopeSchema, jsonRpcEnvelopeSchema, sdkMessageEnvelopeSchema, threadIdentityEnvelopeSchema, threadContextWindowUsageEnvelopeSchema, errorEnvelopeSchemazodEnvelope schemas for decoding.
ProviderRequestDecodeError, ProviderResponseEncodeErrorclasses (code = -32602)Typed decode/encode failures.
shouldAutoDenyInteractiveRequest({ permissionEscalation }) => booleanThe one statement of when an interactive request is auto-denied (permissionEscalation === "deny").
ZERO_TOKEN_USAGE, addTokenUsageconstant; (total, last) => totalKeep a session's running usage total for the usage delta.
buildShellEnvOverrides(envVars?) => Record<string, string>Drop env names a shell would refuse.
withoutBridgeRuntimeEnv(env) => envStrip bridge-process-only variables before handing env to a child.
sanitizeInheritedChildProcessEnvallowlist functionBuild a child's environment from an allowlist rather than the bridge's own env.
experimental_recordProviderChildIo, experimental_isProviderBridgeRecording experimental(child, { threadId }); () => booleanRecord mode: tee a provider child's stdio; detect record mode.
createProviderVisibilityMetadata({ parseRawEvent, describeParsedRawEvent })Classify raw provider events as normalized | noise | unknown.
isRecord, getRecordProperty, getStringProperty, getRawSdkMessage, toOptionalString, toOptionalRecord, toNonNegativeNumber, extractResultText, normalizeProviderCommandOutput, mimeTypeFromExtensionhelpersDialect-parsing utilities.
bashArgsSchema, textBlockSchemazodWell-known tool argument shapes.
Presentation builders experimentalexperimental_presentationTitle(text) => string | undefined; experimental_presentationDetail(text) => string; experimental_withTitle(presentation, title); experimental_presentationFileName(path); experimental_COMPACTION_PRESENTATION; experimental_REASONING_PRESENTATION; experimental_fileReadPresentation(path); experimental_searchPresentation({ mode: "content" | "path", query }); experimental_webSearchPresentation(query | undefined); experimental_webFetchPresentation(url); experimental_planStepsPresentation(steps); experimental_toolPresentation(tool)The grammar-v3 presentation pieces whose wording is the same for every provider. presentationTitle is the first non-empty line capped at 160 characters with an ellipsis, undefined when there is nothing to headline so the row carries no title; presentationDetail caps at the persisted schema's 280 characters, the constant the server validates with; withTitle stamps a title only when there is one; presentationFileName is the last path segment. The two constants are the compaction ("Compacting context" / "Compacted context", Archive) and reasoning ("Thinking" / "Thought", Brain) rows; the builders are file read (FileText, headlined by file name), search (content: "Searching files" / Search; path: "Finding files" / FolderOpen), web search (Globe, headlined by the query when there is one), web fetch (Browser, by URL), a plan-steps snapshot ("Updating plan" / ListTodo, suppress: true, headlined by the active step) and the generic "Running <tool>" / "Ran <tool>" row under Toolbox. Keep your own vocabulary (which native tool is which kind, how a command headline is unwrapped) beside them.
Maintenance toolkit experimentalexperimental_resolveExecutablePath(command); experimental_readCliVersion(command); experimental_commandOutput(command, args); experimental_versionFrom(text); experimental_compareVersions(left, right); experimental_npmCommand(); experimental_formatCommand(command, args); experimental_npmGlobalInstallCommand(npmPackage); experimental_npmLatestVersion(npmPackage); experimental_probeNpmGlobalPackage(npmPackage); experimental_npmGlobalInstallSource({ installed, executablePath, npmBin }); experimental_installationVerification(status, action); experimental_downloadedInstallerCommand(url); experimental_clampPercent(value)Behind provider/health, provider/usage and provider/installation/* for a user-installed CLI. Probes: the executable's absolute path (the path itself when given absolute and executable, else the first which/where hit, null when absent; 5 s), <command> --version (the first x.y.z[-pre] on stdout or stderr; 5 s), any command's trimmed stdout+stderr or null on failure (15 s), npm view <package> version, and npm prefix -g plus npm list -g as { npmBin, npmGlobalPackageVersion }. Decisions: a semver-shaped compare (numeric core, then a prerelease below its release; anything else reads 0.0.0), the install source (npmGlobal when the executable sits inside npm's global bin, external otherwise, notInstalled when absent), and the verification an install run is checked against (installed for an install; version_at_least the latest the status saw for an update, or version_changed when the registry was unreachable). Actions: npm install -g <package>@latest with its display string, a vendor's curl | bash installer run from a temp file (POSIX only), npm / npm.cmd, and a display line with shell-unsafe arguments single-quoted. clampPercent rounds a usage percentage into 0–100. Your policy (the minimum supported version, the login command, credential and usage readers) stays with you.
experimental_readBoundedLines experimental({ input, onLine, onOverflow, onClose?, maxLineBytes? }) => voidThe newline-delimited reader the daemon reads your bridge's stdout with and the bridge worker reads its stdin with: LF-only framing (never readline, which also splits on U+2028/U+2029), a trailing CR stripped so a CRLF producer parses, and a hard per-line cap (64 MiB by default) past which the line is discarded up to its terminator and reported to onOverflow(bytes), so one runaway message costs its own content and nothing else. A final unterminated line is emitted before onClose. Read a JSON-lines child with it instead of carrying your own copy.

Types: BoundedLineReaderArgs, NpmGlobalPackageProbe, BridgeJsonRpcResponse, BridgeSendError, BridgeToolCallRequest, BuildInteractiveResponseArgs, DecodedInteractiveRequest, JsonRpcMessage, PreparedProviderCommandDispatch, ProviderInboundRequest, ProviderPostInitializeRequest, ProviderRawEventCoverage, ProviderRawEventDescription, ProviderRuntimeEvent, ProviderVisibilityMetadata.

4. Domain vocabulary

Re-exported from bb's domain package and inlined into the bundle: the reasoning-effort constants (LOW_REASONING_EFFORTMAX_REASONING_EFFORT, ULTRACODE_REASONING_EFFORT), reasoningLevelSchema/reasoningLevelValues/reasoningEffortsForLevels, permissionEscalationValues, runtimePermissionScopeValues, instructionModeValues, dynamicToolSchema, jsonValueSchema, extensionKindSchema/isExtensionKind, threadEventItemPresentationSchema, threadEventSearchModeSchema, providerRawEventSchema, providerRecoveryKindSchema/providerRecoveryKindValues, the interaction schemas (interactionRequestPayloadSchema, pendingInteractionResolutionSchema, approvalInteractionOutcomeSchema, userQuestionInteractionOutcomeSchema, providerInteractionOutcomeSchema, the permission-profile schemas, the is* guards), USER_QUESTION_MAX_OPTIONS/USER_QUESTION_MAX_QUESTIONS, removeCommandMentionsFromPromptInput, isStandaloneBuiltinCompactCommand, the background-task status helpers, the ACP CLI schemas (acpReasoningCliSchema, acpNativeReasoningSchema, acpPermissionCliSchema), and toPositiveNumber. Types include PromptInput, DynamicTool, AvailableModel, PendingInteractionPayload, PendingInteractionResolution, RuntimePermissionPolicy, PermissionMode, ReasoningLevel, ServiceTier, ThreadEventItemStatus, ThreadEventTurnStatus, ThreadEventPlanStep, ThreadEventTokenUsageBreakdown, ThreadEventContextWindowUsage, ThreadEventItemPresentation, ClientTurnRequestId, JsonValue, JsonObject, and the workflow snapshot types.

Fifteen unprefixed values and two types of this subpath are scheduled for removal at the next major version (unreferenced by any first-party plugin; kept because 0.4.x published them). Eleven of the names above: acpNativeReasoningSchema, acpPermissionCliSchema, acpReasoningCliSchema, extensionKindSchema, interactionRequestPayloadSchema, isExtensionKind, isUserQuestionPendingInteractionPayload, isUserQuestionPendingInteractionResolution, providerRecoveryKindValues, threadEventItemPresentationSchema, threadEventSearchModeSchema. And four 0.4.15 exports whose definitions moved out of core, restored in 0.4.16 as aliases so a bridge built against 0.4.15 rebuilds: hostDaemonAcpLaunchSpecSchema and normalizeHostDaemonAcpLaunchSpec (the ACP kit's experimental_acpLaunchSpecSchema and its normalizer, which a plugin that declares an ACP agent should import from /provider-bridge/acp), claudeTaskToolNameSchema and claudeTaskToolOutputSchema (the Claude Code task-tool names and outputs as shipped; nothing in bb reads them and there is no replacement), with the types HostDaemonAcpLaunchSpec and ClaudeTaskToolOutput. See Compatibility.

@get-bb/plugin-sdk/provider-bridge/acp #

The Agent Client Protocol is one wire protocol spoken by many agents, so bb runs all of them through one generic bridge. The agent to launch arrives per command in the provider options, and nothing in the bridge is bb-first-party; the bundled ACP plugin is a list of agents anyone could have written. Every value export is experimental; see Compatibility for the open questions.

ExportPurpose
experimental_acpProviderBridgeThe bridge. Re-export it from your bb.host artifact as experimental_providerBridge.
experimental_acpLaunchSpecSchema (type AcpLaunchSpec)The launch spec the bridge parses out of experimental_bridgeOptions.acpLaunchSpec, published so a plugin validates what it declares against exactly what the bridge accepts.
experimental_probeAcpAgent, experimental_acpAgentProbeSchema (types AcpAgentProbe, AcpAgentProbeRequest)Spawn an installed agent, read its initialize reply, kill it: { reachable: true, fork } (the agent implements the unstable session/fork) or { reachable: false, reason }. The request is { command, args, env?, cwd, timeoutMs? }; ten seconds by default. Runs on the host, so call it from a host RPC handler.
types AcpDialect, AcpToolIdentity, AcpDelegationReport, AcpClientRequestOutcome, AcpClassifiedToolCall, AcpToolCallContent, AcpToolCallStatus, AcpToolCallUpdateEvent, AcpToolKind, AcpAgentModelCatalog; AcpAgentProfile, a deprecated alias of AcpLaunchSpec scheduled for removal at the next majorThe dialect hooks (toolIdentity, classifyToolCall, handleClientRequest, maintenance) and wire shapes. The dialect registry is not public yet; the bridge ships generic, cursor and grok, named by id in experimental_bridgeOptions.acpDialect.

The launch spec

FieldTypeNotes
displayNamestringrequired
command, args, envstring, string[], Record<string, string>required; how the bridge spawns the agent per session
cwdstringoptional
modelCli{ listArgs, selectFlag?, primaryModels }how to discover models from the CLI and pin one at launch; an empty listArgs reads as absent
reasoningCli, nativeReasoning, permissionClidomain schemashow reasoning levels and permission modes map onto CLI flags
nativeSkillRoots{ user, project }the agent's own skill roots, the same entry shape and rules as the declaration (relative, no dot segments, unique per side, ancestors on project roots only)

Wrapping an ACP agent

This is the whole plugin for an agent called Amp that speaks ACP over amp acp. No bridge code is written; the host artifact re-exports the kit's bridge, and the declaration carries the launch spec.

host.ts
export { experimental_acpProviderBridge as experimental_providerBridge }
  from "@get-bb/plugin-sdk/provider-bridge/acp";
server.ts
import type { BbPluginApi } from "@get-bb/plugin-sdk";
import { experimental_acpLaunchSpecSchema } from "@get-bb/plugin-sdk/provider-bridge/acp";

const launch = experimental_acpLaunchSpecSchema.parse({
  displayName: "Amp",
  command: "amp",
  args: ["acp"],
  env: {},
  nativeSkillRoots: { user: [".agents/skills"], project: [".agents/skills"] },
});

export default function plugin(bb: BbPluginApi) {
  bb.providers.register({
    id: "amp",
    displayName: "Amp",
    family: "acp",
    icon: "./icons/amp.svg",
    strings: {
      signInHint: "Run `amp login` on the machine to sign in.",
      expiredHint: "Your Amp session expired. Run `amp login`, then reload.",
      installUrl: "https://agentclientprotocol.com",
    },
    experimental_bridgeOptions: { acpLaunchSpec: launch, acpDialect: "generic" },
    experimental_nativeSkillRoots: launch.nativeSkillRoots,
    models: { scope: "host" },
    maintenance: { health: true, usage: false, installation: false },
    capabilities: {
      supportsServiceTier: false,
      supportsNativeUserQuestion: false,
      supportsManualCompaction: false,
      supportsThreadArchive: false,
      supportsThreadRename: false,
      fork: "none",                       // declare "tip" only if the agent advertises session/fork
      permissionModes: ["accept-edits", "full"],
      reasoningLevels: ["low", "medium", "high"],
    },
    composerActions: [],
  });
}

The bundled ACP plugin does the same thing per agent from a definition list, re-registers when its customAgents setting changes, and runs a background service that probes each connected host with experimental_probeAcpAgent to narrow a declared fork the agent turns out not to support. Its manifest bb.host entry re-exports the bridge and default-exports a host entry serving probeAgent and resolveNativeRoots. A user can also add an agent without any plugin at all, through that plugin's customAgents setting (a JSON array of { id, displayName, command, args?, env?, cwd?, dialect?, modelCli?, reasoningCli?, nativeReasoning?, nativeSkillRoots?, permissionCli?, supportsManualCompaction? }); the provider id becomes acp-<id>.

@get-bb/plugin-sdk/provider-bridge/testing #

Framework-agnostic; nothing imports a test runner. The Testing page shows each in use.

ExportSignaturePurpose
experimental_runBridgeConformance({ transport, session, providerId, timeoutMs? }) => Promise<ConformanceReport>Drive a bridge through the scripted scenarios. The transport hands over raw wire messages (send(line), takeMessages(), close?()); the kit assembles every thread/delta batch itself through one real assembler under providerId, reverse-maps the turn ids it names to the bridge, and releases the session it opened at the end. The session fixture is { cwd, promptInput, zeroWorkPromptInput?, interruptiblePromptInput?, options?, icons? }; icons: { pluginId, names } opts into the declared-icon rule. The kit parses the thread/start, thread/resume and (when the handshake declares fork) thread/fork results with the runtime's identity schema, so a bare {} on any of them fails a rule that names providerThreadId.
experimental_formatConformanceReport(report) => stringOne line per rule.
CONFORMANCE_ASSEMBLED_EVENT_METHOD"conformance/assembledEvent"Retired: the kit reads nothing under this name. Kept because 0.4.x published it; removed at the next major.
experimental_createDeltaAssembler, ASSEMBLER_GRAMMAR_VERSIONS({ providerId, textDeltaFlushMs? }) => DeltaAssemblerThe real assembler the daemon runs.
experimental_createBridgeDeltaEventCollector(providerId?) => { assembler, assembleMessage }Feed captured thread/delta notifications through one stateful assembler; throws on an invalid delta.
experimental_assembleCapturedThreadEvents(messages, providerId?) => ThreadEvent[]Assemble a whole capture with a fresh assembler.
experimental_toConformanceMessages() => neverRemoved in 0.4.16: throws on call, naming the replacement (hand experimental_runBridgeConformance the raw captured messages and the bridge's providerId). The stub goes in bb 0.42.
experimental_captureBridgeJsonRpcOutput() => { messages, takeMessages, restore }Patch process.stdout.write and collect the bridge's lines; takeMessages() returns every message since the last call, the conformance transport's drain.
experimental_createBridgeJsonRpcTestHarness(handleLine) => { sendRequest, waitForResponse, hasResponse, flushWork, messages, takeMessages, restore, … }In-process JSON-RPC driver for a bridge's line handler.
experimental_normalizeCalibrationEvents, experimental_describeCalibrationEventsMake whole-session goldens comparable by interning minted ids.
experimental_resolveProviderBridgeLaunch({ modulePath, pluginId, … }) => ProviderBridgeLaunchThe bridge process exactly as the runtime spawns it, through the bootstrap the kit ships.
experimental_replayRecording({ recordingDir, providerId, bridge, createAssembler, planFromCurrentLane?, timeoutMs?, onStderr?, profile? }) => Promise<ParityRun>Drive the recorded runtime lane into the bridge, answer its requests with the recorded answers, assemble what it emits.
experimental_assembleRecordedEvents(recording, createAssembler, providerId)The recording's own bridge lane, assembled.
experimental_compareParity(old, new, allowlist, { provider, cell }) => ParityComparisonDiff two runs' events and rows.
experimental_checkRecordedCellReplay, RECORDED_CONFORMANCE_CELLS(replay) => ConformanceCheckResult[]The recorded-cell verdicts.
experimental_rerecordCurrentBridgeLane, CURRENT_BRIDGE_LANE_FILE({ recordingDir, providerId, bridge, createAssembler, timeoutMs? })Write the bridge's current output beside a recording that is never rewritten (bridge→runtime.current.ndjson).
experimental_readBridgeRecording, experimental_listRecordedCells, experimental_withCurrentBridgeLaneRecording readers.
DEFAULT_REPLAY_PROFILE, PARITY_INITIALIZE_IDconstantsThe replay profile for a bridge with no provider child; the id the harness uses for its own initialize.

Types: BridgeConformanceTransport, ConformanceCheckResult, ConformanceReport, ConformanceSessionFixture, RunBridgeConformanceOptions, DeltaAssembler, CreateDeltaAssemblerOptions, AssembleDeltasArgs, BridgeDeltaEventCollector, BridgeJsonRpcTestHarness, CapturedBridgeJsonRpcOutput, CapturedBridgeNotification, RecordedCellReplay, RecordedConformanceCell, BridgeRecording, BridgeRecordingManifest, BridgeRecordingEntry, BridgeRecordingDirection, RecordedCell, ParityRun, ParityComparison, ParityAllowlistEntry, ReplayProviderProfile, ReplayDialect ("json-rpc" | "claude-cli" | "pi-rpc"), and the canonical event types ThreadEvent, ThreadEventItem, ThreadEventItemPresentation and the named item kinds.

@get-bb/plugin-sdk/ai-services #

ExportShape
experimental_aiServicesHostContract{ "ai.inference.complete": { input, output }, "ai.voice.transcribe": { input, output } }
experimental_aiInferenceCompleteInputSchema{ serviceId, model, reasoningEffort: "none", prompt, outputSchema, timeoutMs }
experimental_aiInferenceCompleteOutputSchema{ ok: true, model, value } | { ok: false, code, message }
experimental_aiVoiceTranscribeInputSchema{ serviceId, model, audioBase64, mimeType, filename, prompt: string | null, timeoutMs }
experimental_aiVoiceTranscribeOutputSchema{ ok: true, model, text } | { ok: false, code, message }
experimental_aiServiceErrorCodeSchematimeout | rate_limited | service_unavailable | auth_required | request_failed | invalid_response

Types: ExperimentalAiServicesHostContract, ExperimentalAiInferenceCompleteInput/Output, ExperimentalAiVoiceTranscribeInput/Output, ExperimentalAiServiceErrorCode. (experimental_aiServiceKindSchema, ExperimentalAiServiceKind and ExperimentalAiJsonValue are gone; the declaration's kinds are typed by PluginAiServiceKind on the root entry.)

@get-bb/plugin-sdk/app #

ExportSignaturePurpose
experimental_useProviders experimental() => { status: "loading" | "ready" | "error", providers: ProviderInfo[] }The provider directory in picker order, from the host's own cached roster.
app.slots.experimental_timelineRenderer experimental({ kind, component })Render the expanded body of rows this plugin owns: one of its extension kinds, or "tool" for its providers' generic tool items. Props: { row: { id, threadId, turnId, kind, toolName, status, startedAt, completedAt }, payload, presentation, thread: { id, providerId }, Original }.
app.slots.experimental_providerIcon experimental({ providerId, icon })An inline React mark for one provider; icon receives only className.
app.slots.pendingInteraction({ id, component })The form a plugin-defined request (<pluginId>/<id>) renders with. Props: { interaction: { id, threadId, title, payload, createdAt, expiresAt }, submit(value), cancel() }.
experimental_ProviderModelPicker, experimental_PermissionModePicker experimentalcontrolled componentsbb's own provider/model/reasoning picker and permission picker, for plugin surfaces that start threads.
import { definePluginApp, experimental_useProviders } from "@get-bb/plugin-sdk/app";

export default definePluginApp((app) => {
  app.slots.experimental_timelineRenderer({
    kind: "echo-provider/receipt",
    component: ({ payload, presentation, Original }) => (
      <div>
        <Original />
        <pre>{JSON.stringify(payload, null, 2)}</pre>
      </div>
    ),
  });
});

@get-bb/plugin-sdk/testing and /testing/host #

ExportSignaturePurpose
createFakePluginHost({ pluginId, … }) => { bb, harness }A fake bb host whose bb satisfies BbPluginApi and runs the same declaration validator the server does. harness.registrations.providerRegistrations holds what you registered, normalized.
createFakeSdk, makeThreadResponseFixtures for bb.sdk.
experimental_scanPublicSdkOnly experimental(packageRoot, { allow?: RegExp[] }) => { files, violations: [{ file, specifier, reason: "private-package" | "outside-allowlist" | "outside-package" | "dynamic-specifier" }], privateDependencies }Walk every .ts/.tsx/.js file below the package root (skipping node_modules and dist) and report each import that is a private @bb/* package, falls outside the allowlist (@get-bb/plugin-sdk and its published subpaths, zod, node: built-ins, relative paths that stay inside the package root, plus the public packages named in allow; test files may add the published testing subpaths and vitest), is a relative path that resolves outside the package root (outside-package, unless an allow pattern names it; specifier is the path as written), or is an import()/require() whose argument is not a string literal (dynamic-specifier; specifier is the argument text). privateDependencies is the @bb/* names in package.json. Returns data and imports no test runner; the suite asserts. Types: PublicSdkOnlyScan, PublicSdkOnlyScanOptions, PublicSdkOnlyViolation.
experimental_createHostEntryHarness (/testing/host) experimental(entry, { experimental_paths?, experimental_watch? }) => harnessCall one host handler through the daemon's validation, JSON and size boundaries: experimental_call(method, input), experimental_getSignals(), experimental_getRetainedWorkerLeaseCount(), experimental_lifecycleSignal, experimental_dispose().