FAQ and gotchas
Short answers, each pointing at the rule behind it.
Can one plugin ship two bridges? #
No. A plugin has one bb.host artifact and the artifact has one experimental_providerBridge export. A plugin that declares several providers ships one bridge for all of them; the bridge tells them apart by the providerId on maintenance requests and by what arrives in providerOptions and experimental_bridgeOptions. The ACP plugin runs every agent it registers through one bridge this way. Naming a second bridge per plugin is an open audit question, not a feature.
Can I ask bb to special-case my provider? #
No, and it cannot special-case its own either. Core keeps no table of provider ids or tool names: rendering comes from your presentation, recovery from your typed hint, copy from your strings, the icon from your declaration, the install and sign-in story from your maintenance answers. If you find a behavior you cannot express, that is a public-primitive gap worth reporting, not a branch to request.
Is presentation really required? #
On an extension item, yes: the delta schema rejects an open or close without it, because nothing else can render the row. On core shapes it is optional on the wire today, while rows persisted before bridges stamped it are upgraded at read time, and it becomes required together with the migration that stamps those rows; every first-party bridge and the echo example already attach it to every item.open and item.close. Attach it everywhere now, and take the provider-independent rows from the kit (experimental_fileReadPresentation, experimental_searchPresentation, experimental_toolPresentation, …) so your rows read like everyone else's. A generic tool row without presentation is read through the legacy-data adapter (upgradeLegacyToolItem, applied when a stored row is parsed, keyed on the absence of presentation and never on a provider id): a read/grep/glob/find/ls name becomes a fileRead or search row with a path or query and no output, Task*/Todo*/ToolSearch bookkeeping calls collapse, an Agent/Task/spawnAgent/resumeAgent call with no child row classifies as a delegation row (a call with children is a delegation structurally, whatever its name), and anything else renders as the generic tool row with a plain name. That is how the pi bridge's generic tool rows render today; the window closes when pi stamps presentation and the backfill migration makes it required (Compatibility). A bridge that presents a tool it happens to call Agent gets the generic tool row it asked for: for every new event, delegation is a delegation item or a child row naming the call as its parent, never a tool name.
Which permission modes should I declare? #
Only the ones the agent can actually run in, from the closed enum "accept-edits" | "auto" | "full", non-empty and without duplicates. The server clamps the thread's mode to the host's ceiling and to your list, and the modes auto-decide approvals (accept-edits approves file changes; auto approves commands, file changes and tool uses; full approves all). A bridge that enforces policy itself says approvalEnforcedBy: "provider" at the handshake; otherwise forward every approval and let the runtime decide. Pi declares ["full"] only; the ACP agents declare ["accept-edits", "full"].
Declared or reported? #
A fact is declared when an external consumer needs it before any session exists (picker rendering, route gating, whether to probe maintenance at all). A fact is reported at initialize when only the code that implements it can know (sessionRestore, threadArchive, threadRename, threadGoalClear, fork, approvalEnforcedBy, steerMode, grammarVersions, skills.configure). Never both. Where a fact appears in both places (fork), the handshake may narrow the declaration and never widen it.
My agent speaks ACP. Do I need a bridge? #
No. Re-export experimental_acpProviderBridge from your host artifact as experimental_providerBridge and put an acpLaunchSpec (validated with experimental_acpLaunchSpecSchema) in experimental_bridgeOptions. Name a dialect (generic, cursor, grok) with acpDialect when the agent has vendor side channels the kit knows how to read; the spec's nativeSkillRoots is { user, project }, the declaration's own shape. Declare fork: "none" unless the agent advertises session/fork (the kit's probe, experimental_probeAcpAgent, answers { reachable: true, fork } or { reachable: false, reason }, and the bundled plugin uses it to narrow a declared fork): the bridge refuses a fork the agent never advertised, but only after bb has created the fork thread, so an optimistic declaration is a thread that dies on start. A user can also add an agent with no plugin at all through the bundled ACP plugin's customAgents setting. See wrapping an ACP agent.
How do version and install gates work? #
Declare maintenance: { health: true, installation: true } and answer provider/health with a status (ready, not_installed, unauthenticated, expired, unsupported_version, unknown), installedVersion, minimumSupportedVersion, canInstall, canUpdate and loginCommand. Answer provider/installation/status with needsUpdate, versionUnsupported and an installAction carrying a display-only command. A status request may carry requirement: "thread_rewind"; you own the minimum version that operation needs (the Codex bridge, for example, gates thread rewind on its provider version this way) and report it through the ordinary status. When the user clicks install or update, provider/installation/run rechecks and returns a typed command plus a verification rule; the daemon runs it, never the bridge. A provider with experimental_visibility: "installed" is hidden from the picker until health says it is not not_installed.
Why did my plugin fail to load? #
The common causes, each with a message naming the cause: declaring a provider without a bb.host entry; a provider id already live from another plugin (first wins); a declaration that fails validation (an id with uppercase letters, an empty permissionModes, more than 32 native roots on a side, an absolute side on a native-root declaration, a fallback list without exactly one default, a renamed field such as experimental_strings, a provider icon or a tool presentation.icon of the form "<pluginId>/<name>" that names another plugin or an undeclared icon); a bb.branding.experimental_icons entry whose file is missing, over 32 KiB, or refused by the SVG validator, or a bb.branding.icon written in the namespaced form; registering an AI service without a host entry or with a reserved id; a host artifact that imports a private @bb/* package. A declared bb.host entry that fails to build is a different case: the load fails on the build error (the actionable one), the plugin's providers stay listed as unavailable with that error in the picker, and an AI service the same plugin registered is staged unbound and never goes live; a fixed build brings both up on reload. The fake plugin host refuses the same registrations with the same messages (Testing). The bridge bootstrap refuses an artifact that exports no experimental_providerBridge, one built without experimental_defineProviderBridge (wrong experimental_apiVersion), or one without a handleLine function, naming the plugin in its message.
Why can't my bridge see an environment variable? #
Provider processes are spawned with every inherited BB_* variable stripped and the rest built from an allowlist. Name the variables your bridge needs in the declaration's env.passthrough (Claude Code names BB_CLAUDE_CODE_EXECUTABLE; Pi names BB_PI_BRIDGE_COMMAND and BB_PI_BRIDGE_ARGS). Build your children's environments the same way and never leak your own inherited env downward; withoutBridgeRuntimeEnv also strips the record-mode directory so a recorded child never records itself.
Where do my provider's own settings go? #
In your plugin's settings (bb.settings.define), read by deriveProviderOptions(ctx.settings) and returned as the JSON bag the bridge receives on every command as options.providerOptions. Core never adds a provider-named field to the shared execution options; memory toggles, subagent switches, a native plan flag and an agent launch spec all travel this way. Parse the bag in the bridge with your own schema and fall back to defaults when it is missing, since the conformance kit and the runtime unit suites send none.
What must happen before I answer thread/stop? #
Everything you still owe for that thread. The runtime detaches the thread the moment the stop is answered, whichever the intent, so the interrupted turn's terminal turn.boundary, explicit closes for your open items, and the release of any per-thread resource (a CLI child, an SDK session) must be on the wire before the response. A release must fabricate nothing. A provider that settles asynchronously waits for it, bounded, and settles the turn itself on timeout. Conformance rules: stop/release-not-interrupted and stop/interrupt-settles-before-result.
I had to rebuild the agent's session. Now what? #
Emit the settling deltas for in-flight work first, then session/replaced { threadId, providerThreadId, reason, contextLost }, then thread/identity and a session.reset delta for the new session. A silent rebuild is a conformance failure; invisible session replacement is how hours of background work once died.
Who mints ids? #
Three families, three owners: threadId is the server's and you echo it verbatim; providerThreadId is the provider's session handle, returned on the thread/start, thread/resume and thread/fork result (required) and echoed by thread/identity; every turn and item id on a ThreadEvent is minted by the runtime's assembler, never by the provider and never by the bridge. You forward provider-native ids as join keys (key.providerItemId, providerTurnId, parentRef, callId with providerNativeIds: true) and the assembler translates in both directions, including the bb turn ids on turn/steer.expectedTurnId and thread/stop.activeTurnId, which reach you reverse-mapped to your own.
Must thread/start return providerThreadId? #
Yes, and so must thread/resume and thread/fork. The result is { providerThreadId, sessionRestorable? }; the runtime reads the provider identity from that result and from nowhere else, adopts no session without it, and no longer waits for a thread/identity notification to fill the gap. A result that lacks the field is rejected with the field named: Invalid JSON-RPC result for thread/start: providerThreadId: …. The conformance kit's session/start-identity rule says the same thing in its failure detail, session/resume-identity parses the resume result the same way and adopts the resumed identity for every later request, and session/fork-identity does it for thread/fork when your handshake declares fork; a bridge that answered resume or fork with a bare {} used to pass the suite and is now caught. Send thread/identity anyway, before any thread/delta for the session: it records the identity ahead of (or again after) the result, and the ordering rule still holds. A stateless bridge that has no handle of its own mints one, as the echo does (echo_<nonce>_<n>).
The agent says it is already archived #
Answer thread/archive with success. bb's thread/archive and thread/unarchive ask for a final state, so a provider whose own call is not idempotent (Codex's app server fails to archive an archived rollout and to unarchive a live one) is yours to absorb: treat those two refusals as success. The runtime propagates any other rejection verbatim and keeps no table of provider messages. thread/discard may be an archive underneath, but a discard of an unknown session keeps its failure visible.
The agent did nothing. Do I still emit a turn? #
Yes. A prompt the provider handles without doing work still produces input.accepted plus either a turn.open + turn.boundary pair or a turn.boundary { claimIfIdle: true }. Zero-delta acceptance is the hung-thread class; rule turn/settles-without-activity checks it when you supply a zeroWorkPromptInput fixture.
Can I ship my own icons? #
Yes. Declare them in the manifest under bb.branding.experimental_icons ({ "receipt": "./icons/receipt.svg" }: names in [a-z0-9-], at most 48 characters and 64 entries; plugin-relative .svg files of at most 32 KiB that pass a reject-only validator), then name one by its namespaced glyph "<pluginId>/<name>" in a row's presentation.icon, in a bb tool's presentation.icon, or as the provider declaration's icon. The server validates the files at build and load, serves them hashed from /api/v1/plugins/<id>/assets/icons/<name>.svg, and replaces at ingest a row whose glyph names another plugin or an undeclared name with a provider/unhandled naming the glyph. Clients draw the SVG as a currentColor mask (web) or a tinted native SVG (mobile), so ship monochrome shapes. bb.branding.icon itself stays a host glyph name or a ./ SVG path; it refuses the namespaced form. Still a name, never bytes or a path: presentation.icon is { glyph } only. See Plugin icons.
What happens to rows after my plugin is uninstalled? #
They keep rendering. Presentation is persisted with the row, so the label, headline, detail, tint and glyph survive the plugin; a row persisted with "<pluginId>/<name>" persists the name, and clients resolve it against the plugin inventory at render time. While the plugin is merely disabled the icon still resolves (declared icons ride the plugin's identity, like its compact branding icon). Once it is uninstalled, or once it stops declaring that name, the icon is simply not found and the per-kind fallback glyph draws (Puzzle for an extension item, Terminal for a tool). Rows are never rewritten when your map changes; that is the accepted trade-off for never depending on a file that may have moved. A row's body that a web renderer upgraded falls back to the declarative base the same way.
How do I ask the user something custom? #
Register a form with app.slots.pendingInteraction({ id, component }) in your frontend (lowercase [a-z0-9-] id), then raise interaction/request with payload: { kind: "<pluginId>/<id>", title, data }. The answer returns as { kind: "request_answer", value }. No permission mode answers it; it is an open request, the phone shows a card pointing at the desktop app, and there is no cancel other than stopping the turn. If the agent's own question fits bb's multiple-choice shape, raise a user_question instead and bb renders it everywhere. If the agent has a native question tool, declare supportsNativeUserQuestion: true so bb's own question tool is not registered twice.
Should I send the hint in the error or as a notification? #
Rejecting a request? error.data.recovery, by throwing experimental_BridgeRecoveryError inside runBridgeRequest or by passing { recovery } to sendError. No request to reject (an auth failure mid-turn)? The provider/recovery notification with the threadId. Never both for one event, and never text the runtime would have to match. A timeout or a bridge exit has no response and therefore no hint.
Should models.scope be host or workspace? #
"host" when the bridge answers model/list from account or agent state and ignores the workspace path (Codex, Claude Code and every ACP agent declare it): bb probes once per machine. "workspace", the default, when project configuration can change the answer: bb probes per workspace and sends cwd. Wrongly declaring "host" is a stale catalog in a workspace that configured its own models; wrongly declaring "workspace" only costs a redundant probe.
Can I rename my provider id? #
No. Thread rows persist it, routes reference it, and a user-configured ACP agent's acp-<id> never changes once a thread has used it. Change the displayName freely; keep the id.
What if two plugins register the same id? #
The first live registration wins; the later plugin's load fails with the collision named. No id is reserved ahead of time, so a disabled first-party plugin's id is claimable until it is re-enabled (which then fails to load). Pick ids unlikely to collide; a namespace rule is an open audit question.
Can I emit another plugin's extension kind or icon? #
No. Both are held to the same emitter rule at ingest: the thread's provider names the plugin that wrote the row, and a <pluginId>/<name> extension kind (item or state) or presentation glyph must be that plugin's. A kind another plugin owns is refused before its schema is even looked up, and a thread whose provider has no live registration has nothing to vouch for its rows, so every extension kind on it is refused. The refused event is replaced, in its batch slot, by a provider/unhandled whose reason names the kind, the owning plugin and the plugin behind the thread's provider. Clients pick a kind's renderer by the kind alone, which is why the rule exists: without it a bridge could persist another plugin's goal banner or widget on a thread that plugin never touched. Declare your own kinds under your own id and emit only those.
Will my extension rows show on the phone? #
Yes, from their presentation: label, glyph, tint, headline and detail. Mobile loads no plugin JavaScript by design, so make the declarative base good before you write a web renderer. A declared icon ("<pluginId>/<name>") reaches the phone too: mobile resolves it against the installed-plugin list, fetches the SVG and draws it tinted, with the host glyph as the fallback while it loads. Presentation is persisted, so the rows keep rendering after the plugin is uninstalled or upgraded. Two mobile specifics: a tint React Native cannot paint (oklch(), oklab(), lab(), lch(), color(), a percentage alpha) falls back to the neutral row colour per theme side, while hex, rgb()/rgba()/hsl()/hsla()/hwb() with a numeric alpha and named colours paint; and an image in detail renders as its alt text, on the phone as on the web.
I read the timeline over HTTP. Which rows do I get? #
The current rows, always. GET /threads/:id/timeline and …/timeline/turn-summary-details serve the grammar v3 projection to every reader: the file-read, search, plan-steps and extension work rows beside command, tool, file-change and the other kinds. There is no capability header to send and no legacy projection to fall into: bb.sdk (and so the web app, the mobile app and the CLI) and a raw HTTP client receive the same v3 rows. A tool row carries no activityIntents key; exploration arrives as file-read and search rows, and only command rows carry parsed intents. Older mobile builds are not supported: the app ships with the server, so the timeline wire serves grammar v3 only (Compatibility).
dependencies or devDependencies? #
For a provider plugin, put @get-bb/plugin-sdk in dependencies, as the echo example does: the /provider-bridge subpath is real schema and helper code the artifact build inlines, not a build-time stub. (The generic guidance for a host-only plugin, which keeps the SDK in devDependencies because its host members are stubs the builder supplies, does not apply once you import /provider-bridge.)
Your server entry may import the SDK subpaths too (@get-bb/plugin-sdk/host, /provider-bridge, /provider-bridge/acp). bb plugin build now inlines those subpaths into dist/server.js from your own SDK install, as it does for the host artifact, and leaves only the bare @get-bb/plugin-sdk specifier external, which the server's loader aliases to its runtime bundle at load time. Earlier builds left every subpath external as well, and the loader serves nothing but the bare specifier, so a packaged install (git or npm) failed to load a plugin whose server entry imported one. You change nothing: keep the SDK in dependencies and rebuild.
My bridge hangs after I added a console.log #
Stdout is the protocol channel. A stray line is ignored by the reader, but a stray write that splits a JSON line corrupts it, and a library that writes progress to stdout will do exactly that. Log to stderr; the daemon collects it. Keep one writer for protocol traffic, as the echo bridge does, and in record mode the bootstrap's tee sees every line it writes.
Can deriveProviderOptions pass an API key? #
No. Secret settings are omitted from the settings the hook receives, because provider options ride the daemon wire and are persisted with the session. Credentials live on the host: the agent's own login, a file in the bridge's dataDir, or an environment variable named in env.passthrough.