Concepts
One section per idea. Each states the rule, the shape on the wire, and who enforces it.
The bridge protocol #
The Provider Bridge Protocol is the one JSON-RPC contract between the agent runtime (inside the host daemon) and every bridge process. The rule of thumb: the bridge knows the dialect, the runtime knows the timeline.
Transport
- Line-delimited JSON-RPC 2.0 over the bridge process's stdin and stdout, in both directions. Requests and responses are told apart by the presence of
method, never by result shape. The two directions use independent id spaces, and both number their outgoing requests from 1, so a bridge must checkmethodbefore it settles a pending promise. - An undecodable or schema-invalid request is answered with
INVALID_PARAMS(-32602) carrying the validation issues; a dropped request is an undebuggable 30-second timeout. An unrecognized method answersMETHOD_NOT_FOUND(-32601). Non-protocol stdout is ignored by the reader; guard stdout against stray writes. - Lines are bounded: the bootstrap reads stdin with a 64 MiB per-line cap and discards an oversized line rather than growing without bound.
Versioning
initialize exchanges { protocolVersion, capabilities } in both directions. The protocol version is 2; the runtime rejects any other with a legible startup error. The version bumps only for breaking changes. Everything additive rides capability tolerance: unknown methods answer -32601, unknown notifications are ignored, unknown capability fields pass through. Bridges version with their plugin, not with the daemon.
The thread/delta grammar is versioned separately. The runtime states its assembler's range in the initialize params and the bridge states its own in the result; both use the highest common version. The assembler speaks v3 only, so every bridge reports grammarVersions: [3, 3]; a bridge that omits the field reads as [2, 2] and is refused at spawn.
Handshake capabilities
These are session-behavior facts the bridge reports because it implements them. Each defaults on parse so an older bridge that omits one is read as not having it.
| Capability | Default | Meaning |
|---|---|---|
grammarVersions | [2, 2] | Inclusive [min, max] of the delta grammar the bridge speaks. Report [3, 3]. |
sessionRestore | false | A released session can be re-attached later from its persisted providerThreadId. Per-session sessionRestorable on thread-identity results refines it. |
threadArchive | false | The bridge mirrors bb archive state into the provider. When false the runtime never sends thread/archive or thread/unarchive. |
threadRename | false | The bridge pushes bb thread titles to the provider (thread/name/set). |
threadGoalClear | false | The bridge supports thread/goal/clear. |
fork | "none" | "none" | "tip" | "checkpoint". May only narrow the declaration's capabilities.fork, never widen it. |
approvalEnforcedBy | "runtime" | "runtime": forward every approval and let the runtime apply the thread policy. "provider": the bridge enforces policy first, so every forwarded request already needs the user. |
steerMode | "queue" | "inject" feeds a turn/steer into the running model loop; "queue" holds it for the next prompt boundary. Nothing in the runtime, server or clients reads it yet: turn/steer is sent either way, and a steer whose turn is gone is dropped on your staleTurn hint or NO_ACTIVE_TURN error whatever the mode. Declare the truth anyway. |
skills.configure | false | The bridge handles skills/configure. When false the runtime never sends it and the thread runs without injected skills. |
One process per provider artifact, lanes per thread
The daemon spawns one bridge process per provider artifact, and that process serves every thread of the provider(s) the artifact owns. Per-thread state is the bridge's own business: most bridges keep a map from bb threadId to a session object that holds the provider's session handle and any child process. The runtime never scopes processes per thread and never matches error text.
The request vocabulary
Runtime → bridge: initialize, model/list, thread/start, thread/resume, thread/fork, thread/stop, thread/discard, thread/name/set, thread/archive, thread/unarchive, thread/goal/clear, turn/start, turn/steer, skills/configure, and the sessionless provider/health, provider/usage, provider/installation/status, provider/installation/run. Bridge → runtime notifications: thread/delta, thread/identity, session/replaced, provider/raw, provider/recovery, error. Bridge → runtime requests: item/tool/call, interaction/request. Params and results are in the reference.
Execution options ride every session and turn command and carry no provider-named field: { model?, serviceTier?, reasoningLevel?, promptMode?, instructions?, envVars?, providerOptions? } plus the runtime permission policy. The runtime never diffs them; the bridge reconciles internally and reports a rebuild through session/replaced.
Sessions and the stop rule
thread/start,thread/resumeandthread/forkanswer{ providerThreadId, sessionRestorable? }.providerThreadIdis required on that result. The runtime reads the provider identity from the result and from nowhere else: it adopts no session without the field, athread/identitynotification does not stand in for it, and a result that lacks it is rejected with the field named (Invalid JSON-RPC result for thread/start: providerThreadId: …). Before anythread/deltafor the session, the bridge still sendsthread/identity(which records the identity ahead of, or again after, the result) and asession.resetdelta: every session construction is a provider id-space boundary.thread/archiveandthread/unarchiveask for a final state. A provider whose own archive call refuses a duplicate (Codex's app server fails to archive an archived rollout, or to unarchive a live one) is the bridge's to absorb: answer the duplicate as success. The runtime propagates a bridge's rejection verbatim and special-cases no provider's message.thread/discardis an archive underneath for such a provider but keeps its failure visible.- Session replacement is never silent. A bridge that tears down and rebuilds a live session first emits the settling deltas for in-flight work, then
session/replaced { threadId, providerThreadId, reason, contextLost }. thread/stopcarries a requiredintent:"interrupt"settles the active turn as interrupted (the bridge emitsturn.boundary { status: "interrupted" }and explicit closes for its open items);"release"detaches an idle session and must fabricate nothing.- After
thread/stopthe bridge holds nothing for the thread. The runtime detaches the thread the moment the stop is answered, whichever the intent, so every delta the bridge still owes for that thread (the interrupted turn's terminal boundary first of all) must be on the wire before the response, and any per-thread resource (a provider CLI child, an SDK session) is released before or with it. A provider that settles asynchronously waits for it, bounded, and settles the turn itself on timeout. Conformance rule:stop/interrupt-settles-before-result. - Open work is what the timeline says it is: a pending
backgroundTaskordelegationitem keeps the runtime from reaping the session. Model a native sub-agent as a delegation, and settle it (as failed) when your provider child dies, or the runtime keeps refusing to reap a thread that no longer exists on your side.
Ordering
Producers guarantee that thread/identity precedes any delta for the session, that deltas within a turn are in presentation order, and that settlement deltas precede the session/replaced that made them necessary. Consumers must not assume that a request's response arrives before the notifications it caused (turn/started may precede the turn/start response), and must assume nothing about provider/raw, which is droppable at any pressure point.
Item grammar v3 #
Everything timeline-bound rides one notification, thread/delta { threadId, deltas }. A delta is a parsed semantic unit, never a raw provider event and never a finished event. The runtime's delta assembler owns every timeline invariant: it mints turn and item ids, pairs opens with closes, accumulates streamed text, throttles progress, and constructs the canonical ThreadEvents. Deltas carry provider-native join keys (key.providerItemId, key.channel, key.parentRef, providerTurnId) and the assembler holds the maps in both directions, so a bridge does zero id translation.
Core item kinds
The kinds core acts on: message, reasoning, command, fileChange, fileRead, search, webSearch, webFetch, imageView, delegation, planSteps, compaction, tool. On the wire an item.open/item.close carries one of these shapes:
Shape type | Fields | Use it for |
|---|---|---|
command | command, cwd, aggregatedOutput?, exitCode?, durationMs? | A shell command. Stream output with item.outputDelta { channel: "command" } or command.outputSnapshot. |
fileChange | changes: [{ path, kind: add|update|delete, movePath?, diff?, oldText?, newText? }] | Edits. The bridge states the change kind; the assembler builds a unified diff from old/new text when no diff is given. |
fileRead | path, cmd? | A file the agent read (a structured tool, or a cat/sed -n command the bridge classified). |
search | mode: content|path|list, query, path?, cmd? | Grep, glob and directory listing as one shape. |
delegation | childRef, label, background, summary? | Delegated work. The child's deltas link back through parentRef; background: true outlives its turn. |
planSteps | steps: [{ step, status }], explanation? | A plan snapshot; each supersedes the previous one. |
tool | tool, server?, args?, result?, error?, durationMs? | Any tool with no core kind. Stamp server: "bb" and the definition's presentation on calls to bb-injected tools. |
agentMessage, plan | text | Assistant text; streamed with item.textDelta. |
reasoning | summary[], content[] | Thinking; channels reasoningSummary and reasoningText. |
webSearch, webFetch, imageView | queries[] / url, prompt?, pattern / path | Web and image tools. |
compaction | — | A context compaction the provider performed. |
backgroundTask | familyId, taskType, description, status, taskStatus, skipTranscript, … | Provider background work, re-embedded as a snapshot per event. |
extension | kind: "<pluginId>/<name>", payload | Your own kind. Presentation is required on its open and close. |
The delta kinds
- Turns:
input.accepted { clientRequestId }(mandatory for every acceptedturn/startorturn/steer),input.provider,turn.open { providerTurnId?, parentRef? },turn.boundary { status, error?, providerCheckpointId?, claimIfIdle? },turn.diff. - Items:
item.open { key, item, presentation?, attach? },item.close { key, status, item, presentation?, resultText?, exitCode?, aggregatedOutput?, approvalStatus? },item.progress { key, message?, snapshot?, flush? }. - One streaming dialect:
item.textDelta { key, channel, text }anditem.textClose { key, channel, text? }, with channelsagentMessage,reasoningSummary,reasoningText,plan. A delta for an unknown key synthesizes the item's open.item.outputDelta { channel: command|fileChange }andcommand.outputSnapshotnever synthesize an open. - One usage dialect:
usage { total, last, modelContextWindow }plus the separatecontextWindow { used, size?, estimated, attach }. A provider that reports per turn sumslastintototalitself (addTokenUsage), resetting at everysession.reset. - Thread state:
extension.state { extensionKind, payload }(latest snapshot wins per kind),provider.rateLimits,context.compacted,context.cleared,thread.started,thread.identity,thread.name. - Diagnostics:
provider.error,provider.warning,provider.modelFallback,unhandled { raw, rawType, vouchedTurn, onlyIfNoTurn? }. - Lifecycle:
session.ended(closes open turns and items as interrupted),session.reset(drops all assembly state for the thread).
Rules the assembler holds you to
- Item and stream deltas never open a turn. Only
turn.open, a claimingturn.boundaryand accepted-input settlement do. A turn-scoped delta with no turn to attach to surfaces itsnoTurnFallbackas a thread-scopedprovider/unhandled, or is dropped. - Every item's first event is
item/started.item.closealways carries the full terminal shape; the assembler settles uniformly (paired close, reclassifying dual-settle, or bare close-without-open). Repeated closes for a settled provider-identified key are deduped. - Ids are unique for the life of a thread, including resumes, because the assembler mints them and
session.resetstarts a fresh provider id space. - A prompt the provider handles without doing work still produces a
turn.open+turn.boundarypair, or aninput.accepted+ claiming boundary; zero-delta acceptance is the hung-thread class. - Completion follows content: if the provider emits a completion before the output it refers to, hold the close and flush in order. Output may be delayed, never lost.
- Turn scoping is vouched: only turn keys the bridge itself opened may scope a delta. A provider's internal turn labels must never be forwarded as scoping.
The host also applies the grammar live at its event intake (ThreadEventGrammar): a streaming event for an item nobody opened, a second settlement, a duplicate turn start or completion, or a completion for a turn that never started is dropped with a warning naming the rule.
Extension kinds
A kind is <pluginId>/<name> in lowercase letters, digits and -. Declare each with an item schema, a state schema, or both. Only the namespace is validated on the wire; the server validates the payload against your schema at ingest (64 KiB cap) and persists a provider/unhandled in its place on a miss, never dropping it and never storing it unvalidated. Before the schema is even looked up, the kind is held to the emitter rule a namespaced presentation glyph is held to: the thread's provider names the plugin that wrote the row (its live registration's pluginId), and that must be the plugin the kind names, for an item and for a state alike. A kind another plugin owns persists as the same provider/unhandled, in the same batch slot, with a reason naming the kind, the owning plugin and the plugin behind the thread's provider; a thread whose provider has no live registration has nothing to vouch for its rows, so every extension kind on it is refused. Clients pick a kind's renderer by the kind alone, which is what the rule protects. Declare your own kinds under your own id and emit only those, as every first-party bridge and the echo example do. Codex's long-running goal (provider-codex/goal, a thread state whose cleared value is null) and the macOS permission profile beside an approval (provider-codex/macos-permission, an item) are extension kinds; nothing about them lives in core.
Presentation #
Presentation is how a client renders an item it has no special code for. It is attached by the bridge at item.open, restated on item.close (the close's value wins; the open's survives when the close carries none), and persisted with the item, so a row renders the same way after the plugin is uninstalled or upgraded, and mobile renders every kind without plugin code.
presentation: {
label: { pending: "Reading file", completed: "Read file" }, // present / past tense
icon: { glyph: "FileText" }, // a host glyph, or your own "<pluginId>/<name>"
title?: "src/index.ts", // row headline beside the label
detail?: "…", // short Markdown, at most 280 characters
suppress?: true, // low-value rows clients collapse by default
tint?: { light: "#1d4ed8", dark: "#93c5fd" }, // accent per theme
}
- Core kinds always use core renderers; presentation customizes them (label, glyph, suppression, tint) and never replaces them.
detailis agent-authored Markdown on every surface it reaches, the row body and the tool-use approval banner, on the web and on mobile: an image in it renders as its alt text, never as a fetch the user did not decide on. A blankdetail(""or whitespace; the schema caps the length, not the content) counts as absent: an extension row with one stays title-only with no chevron, and the CLI formatter skips the line.tintis a plain CSS colour per theme. The forms every client paints are hex,rgb()/rgba()/hsl()/hsla()/hwb()with a numeric alpha, and named colours. The web also paintsoklch(),oklab(),lab(),lch(),color()and a percentage alpha through CSS; React Native's colour parser does not, so on mobile such a tint falls back to the neutral row colour, per theme side, exactly as an unknown glyph falls back, and never to black. bb's own theme is written in oklch, which is why the fallback exists.- Presentation is required on the open and close of an
extensionitem: it has no core renderer, so the declarative base is the only thing every client can show. It is optional on core shapes today: a presentation-lesstoolrow, persisted or live, is read through the legacy-data adapter (keyed on the absence ofpresentation, never on a provider id), which reshapes read/grep/glob/find/ls names intofileRead/searchrows, collapses the Task*/Todo*/ToolSearch bookkeeping calls and classifies a childlessAgent/Task/spawnAgent/resumeAgentcall as a delegation row; the pi bridge's generic tool rows still take that path. It becomes required together with the migration that stamps the old rows and retires the adapter. Codex, Claude Code, the ACP kit and the echo example attach it to every item. Attach it everywhere. - The kit ships the pieces whose wording never varies by provider, so a file-read row built by your bridge reads like one built by any other:
experimental_presentationTitle(the first non-empty line, capped at 160 characters,undefinedwhen empty),experimental_presentationDetail(capped at the persisted 280),experimental_withTitle,experimental_presentationFileName, the compaction and reasoning constants, and builders for file read, search, web search, web fetch, plan steps and the generic tool row. Keep your own vocabulary (which native tool is a command, how a headline is unwrapped, per-tool tables) beside them. Reference. icon.glyphis a name, never bytes or a path. Two vocabularies share the field: a host glyph ("FileText"), or one of your plugin's own declared icons by its namespaced glyph ("echo-provider/receipt", the form"<pluginId>/<name>"that names an entry of the manifest'sbb.branding.experimental_iconsmap). The server refuses at ingest a namespaced glyph that is not the emitting plugin's own declared icon; clients resolve the name against the plugin inventory they hold and fall back to the per-kind glyph when it is not found. See Plugin icons. A path form does not exist; the schema stays a plain non-blank string so persisted rows parse forever.- bb tools carry theirs: every
dynamicTools[]definition the runtime injects arrives with the presentation the server resolved from the owning plugin'sbb.agents.registerTool({ presentation })(or a generic label and the plugin's glyph). Stamp it, besideserver: "bb", on every call to that tool, so no tool-name table labels bb tools anywhere. - A
tool_useapproval is described by the same presentation: the app, mobile, CLI and the child-thread blocker summary render the ask frompresentationalone.
How rows render #
The server folds every item, core or extension, into timeline rows. Core kinds render with bb's renderers on every client. Extension kinds and generic tool items render from their presentation: label, glyph, tint, headline, detail. That is the whole story on mobile, which loads no plugin JavaScript by design.
On the web, a provider plugin's frontend bundle may upgrade the body of the rows it owns with app.slots.experimental_timelineRenderer({ kind, component }), where kind is one of its extension kinds or "tool" for the generic tool items of the providers it registered. The component receives { row, payload, presentation, thread, Original }; the row header stays host-rendered; render <Original /> to keep the declarative base beside your content. With no renderer registered the base renders, so a row never goes blank, and a crash is contained to that row. Provider bundles load lazily and never enter the boot payload: everything a provider plugin's bundle registers (a timeline renderer, a settings section, a nav panel, a palette action, a provider icon, a pending-interaction form) arrives with the bundle, which loads on the first thread of one of its providers, when one of its forms is asked for (a child thread's plugin-defined request surfacing on a parent thread of another provider, say), or when its own panel route is opened. Boot-time UI belongs in a separate, non-provider plugin.
The provider directory is available to plugin frontends through experimental_useProviders() and to backends through bb.sdk.providers.list(); never re-vendor provider names or icons.
Interactions #
A bridge raises an interaction with the interaction/request request: { providerThreadId, threadId?, turnId, payload, providerNativeIds? }. The response is the canonical resolution. Provider-specific request and response dialects never cross the boundary; the bridge maps in both directions. One event type, system/interaction/lifecycle, records every status change of every interaction with the ask and the answer paired by kind; the server fabricates no placeholder items.
Approvals (closed)
An approval payload has kind: "approval" and a subject: command, file_change, permission_grant, or tool_use { itemId, tool, presentation }. A tool_use is any tool call with no core kind (an MCP tool, a provider-native tool); its presentation is the whole description of the ask. Permission modes auto-decide approvals: accept-edits approves file changes; auto approves commands, file changes and tool uses; full approves all. A bridge whose handshake says approvalEnforcedBy: "provider" applies the policy itself and forwards only what still needs a user. The resolution is { decision: "allow_once" | "allow_for_session", grantedPermissions } or { decision: "deny" }. Both sides of the boundary also agree on auto-deny: shouldAutoDenyInteractiveRequest({ permissionEscalation }) in the kit is the one statement of it.
Requests (open)
user_questionrenders with bb's own UI; the answer is{ kind: "user_answer", answers }.plan_review { itemId, plan, planFilePath }renders with bb's plan reviewer.- Plugin-defined:
{ kind: "<pluginId>/<name>", title, data }, wherenameis the id of the plugin'sapp.slots.pendingInteractionregistration (so register a lowercase[a-z0-9-]id),titleis at most 160 characters and the client's fallback when no renderer is installed, anddatais whatever the form reads, capped at 64 KiB. No permission mode answers a request: it reaches the user through the plugin's form on the web app (bb thread interactions respond <id> --value '<json>'from the CLI; the phone shows a card that points at the desktop app). The answer comes back as{ kind: "request_answer", value }, the form's submitted value, capped at 64 KiB. The server accepts a request only while the plugin named by the prefix is loaded. A provider's request has no cancel; backing out stops the turn.valueis untrusted input to the bridge: parse it as you would any client-supplied JSON.
A bridge that keeps the payload it raised parses the wire resolution together with it (providerInteractionOutcomeSchema), so its response encoder narrows on the payload kind and a mismatched pair is a wire error, never a throw inside the encoder.
Recovery hints #
A hint says what went wrong in the provider's own terms and what the runtime may do about it: { kind, message, retryable }. The runtime keys on kind alone and never consults the provider id or matches text.
kind | Runtime action |
|---|---|
sessionArchived | thread/unarchive the session, then retry the rejected request once (retryable: true). |
authRequired | Reject the request with a typed auth_required error and forward the hint so the host can re-check provider health. |
restartRecommended | Stop the bridge process the thread runs on and resume the thread on a fresh one: right away when idle, otherwise before its next turn. |
staleTurn | Drop the steer; the turn it targeted is gone. |
rateLimited | With retryable: true on a rejected request: retry on a short bounded ladder and surface the last failure. With retryable: false: forward only; the runtime never re-runs a user's turn on its own. |
One payload, two carriers. Rejecting a request? Put the hint in error.data.recovery: throw experimental_BridgeRecoveryError({ code, message, recovery }) from a handler run through runBridgeRequest, or call sendError(id, code, message, { recovery }); the JSON-RPC id is the correlation. No request to reject (a terminal 401 mid-turn)? Send the provider/recovery notification with the hint and the threadId. Never both for one event. A timeout or a bridge exit has no response and therefore no hint. A bridge that can heal itself (rebuild its own child before the next turn) does so and still sends the hint so the failure is typed.
Every rejection is acted on. The runtime's per-kind action runs on every rejection of a request, not only the first: a rate-limit ladder rung rejected with another kind is handled by that kind (a staleTurn rung reports the steer stale, a retryable sessionArchived rung unarchives, a restartRecommended rung schedules the restart), and so is the retry after an unarchive, with one bound: a second sessionArchived on that retry is reported, never unarchived again, so the protocol's one retry stays one retry. A process speaks only for the threads it hosts. A provider/recovery whose threadId names a thread the emitting process does not host (another provider's, or one that moved to a replacement process) is dropped with a stderr line, the same rule the event path applies. When a restartRecommended restart re-resumes the process's other threads, it skips a sibling that is live again or on its way (an operation in flight, a turn pending or active), so the daemon's own resume of that sibling is never followed by a second thread/resume.
Models, reasoning levels, service tiers, permission modes #
- Models. The live
model/listresult is the only real model source; it returns{ models: AvailableModel[], selectedOnlyModels }, each model withid,model,displayName,description,supportedReasoningEfforts,defaultReasoningEffort,isDefault. The declaration'smodels.fallbackstands in only while no probe has completed. - Catalog scope.
models.scope: "host"means one answer serves every workspace on a machine (the bridge answers from account or agent state);"workspace"(the default) means project configuration can change the answer, so bb probes per workspace and sendscwd. Declaring"host"wrongly is a stale catalog; declaring"workspace"wrongly costs a redundant probe. - Reasoning levels. The coarse ladder in
capabilities.reasoningLevelscomes from"none" | "low" | "medium" | "high" | "xhigh" | "ultracode" | "max" | "ultra"; the labelledreasoningLevelslist is what the picker shows; the per-modelsupportedReasoningEffortsis precise. The chosen level reaches the bridge asoptions.reasoningLevel. - Service tiers. Open list of
{ id, label }; shown only whencapabilities.supportsServiceTieris true; arrives asoptions.serviceTier. - Permission modes. A closed core enum, least to most privileged:
"accept-edits" | "auto" | "full". Declare the ones the agent can actually run in (non-empty, no duplicates). The server clamps to the host's ceiling and sends the result as part of the runtime permission policy on every command. Pi declares only["full"]; ACP agents declare["accept-edits", "full"]. - Plan mode. Declare
composerActions: ["plan"]and the prompt'spromptMode: "plan"reaches bothderiveProviderOptionsand the bridge's execution options; each bridge maps it onto whatever its agent calls it natively.
Native roots and injected skills #
Injected skills: skills/configure
bb's own skills reach a session through one request shape for every provider: skills/configure { roots: [{ id, path, skills: [{ name, description }] }] }, where path is an absolute directory holding one subdirectory per listed skill (<path>/<name>/SKILL.md). The bridge maps it to the agent's layout (an extra skills root, a local plugin, a prompt listing). The runtime sends it only to a bridge whose handshake declares skills: { configure: true }, once per process, before the first thread command. Conformance rule skills/configure-declared pins both directions.
The agent's own skills and commands
Where an agent keeps its own skills and slash commands is the plugin's fact, declared so bb can list them beside its own and offer them in the composer; core never guesses a provider's layout.
experimental_nativeSkillRootsandexperimental_nativeCommandRootshave two sides:user(relative to the host's home) andproject(relative to the workspace). Paths carry no dot segments; each side holds at most 32 unique roots. A declaration is global, so a directory only one host can name is never declared: a declaration that names anabsoluteside is refused at registration, and a host-absolute directory is always the resolver's answer (below). Host-daemon protocol 163 removed the side from the wire as well.- An entry is a path or
{ path, recursive?, ancestors?, namePrefix?, skipIfManifest? }:recursivescans nested skill directories;ancestors(project roots only) scans the same directory in every ancestor up to the repository root;namePrefixprefixes every name under a vendor plugin's root;skipIfManifestnames a file (Claude's.claude-plugin/plugin.json) that marks a directory as a vendor plugin to skip. - Symlinks out of a project root: a plain root stays within the workspace; a root that walks ancestors, and every root the plugin resolved with project origin, stays within the repository root.
experimental_resolvesNativeRoots: truesays the plugin's host entry implementsexperimental_nativeRootsHostContract. When bb lists a provider's commands or skills it callsresolveNativeRoots({ providerId, cwd })on the workspace host (cached ten seconds per plugin, provider, host and workspace; invalidated when the plugin's settings change or the provider re-registers) and scans the answer beside the declared roots. This is where host-only knowledge goes: a config directory moved by an environment variable, installed vendor plugins, config-file skill entries, including project-scoped entries a global declaration cannot carry. The answer is{ skills?, commands? }of host-absolute roots withorigin: "user" | "project", the per-root options, and ashape(skills,skill,skill-file,commands,command-file) that tells the daemon how to read it; at most 256 per side. A thrown error or malformed answer yields no resolved roots and never fails the listing.- Filter before you answer.
experimental_filterResolvedNativeRoots(answer, { warn })judges each root on its own against the contract, drops a refused one with a warning naming the side, the path and the reason (once per worker per side, path and reason), and cuts each side to the cap, so one odd vendor plugin cannot void the whole listing. A dropped root'sreasonlists 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-skillsshape, a fallback name on a non-skill-fileshape) are judged only on a root whose fields all parse. - Each path once. An answer lists each path once per side, and bb scans each absolute path once per provider across the declared and resolved roots: the first root in declaration order wins (declared skills, project then user; declared commands; then the resolved skills and commands, each in the order given) and a later root with the same path is dropped, so a resolved root that repeats a declared one is listed under the declared root's identity. The kit's vendor-plugin readers keep the first root per path in answer order for the same reason.
- Vendor plugins.
@get-bb/plugin-sdk/hostships the two readers a resolver calls for the plugins installed on a host:experimental_resolveClaudePluginRoots({ cwd, homeDir, env })reads Claude Code's registry (installs, enablement, the project and userskillsdirectories) and answers{ skills, commands, claudeDir };experimental_resolveVendorPluginRoots({ plugins, layout: "claude" | "grok" })walks any plugin directories in that layout and prefixes every root<plugin>:. Symlinks follow the plugin's origin (a personal install's skill components are followed; a checked-in plugin's are not; command components never are). Reference.
Four first-party patterns, all on the same two surfaces: Codex declares .codex/skills and .agents/skills and resolves $CODEX_HOME/skills plus every enabled codex plugin per host, feeding the plugins it reads from config.toml and the cache into experimental_resolveVendorPluginRoots; Claude Code declares .claude/skills (with the ancestor walk and the plugin marker) and .claude/commands, and answers both sides from experimental_resolveClaudePluginRoots beside the claudeDir directories; the ACP plugin's omp and grok resolvers take the readers' skills side; Pi declares its documented defaults (.pi/agent/skills, .agents/skills, .pi/skills) and resolves, per host, the directories its settings.json names and an agent directory PI_CODING_AGENT_DIR moved, every one as a user root. The absolute side Pi once re-registered across hosts is gone.
Maintenance: health, usage, installation #
These are sessionless requests (no thread, no session) the server sends through the bridge only when the declaration's maintenance turns them on. Every one takes { providerId, cwd?, providerOptions? } so one bridge can serve several provider ids, and every result may answer { supported: false } for one id.
provider/health- Cheap, host-local readiness, never a network check:
{ supported: true, health: { status: ready | not_installed | unauthenticated | expired | unsupported_version | unknown, statusMessage, accountEmail, planLabel, installedVersion, minimumSupportedVersion, canInstall, canUpdate, loginCommand } }. Drives the picker's availability andexperimental_visibility: "installed". provider/usage- Subscription usage windows:
{ status: "ok", accountEmail, planLabel, windows: [{ label, usedPercent, resetsAt, cost? }] }, ornot_installed,unauthenticated,expired,error { message }. A provider that declaresusage: falseis omitted from the usage settings. provider/installation/status,provider/installation/run- A deliberately split execution boundary. The bridge owns discovery, version and source comparison, and the install/update decision (for a user-installed CLI the kit's maintenance toolkit does the host-local probing, the version compare and the install-command and verification plumbing; your policy, the minimum version and the login command, stays with you; see the reference);
statusreturns{ executableName, executablePath, installed, installSource: notInstalled | npmGlobal | external, currentVersion, latestVersion, minimumSupportedVersion, npmPackageName, npmGlobalPackageVersion, installAction, needsUpdate, versionUnsupported }with a display-only command. A status request may carry a typedrequirementsuch as"thread_rewind"; the bridge owns the minimum provider version that operation needs and reports it through the ordinary status. When the user acts,run { action: install | update }rechecks and returns{ available: false, message }or{ available: true, command: { command, args, displayCommand }, verification: { kind: installed | version_changed | version_at_least } }. The host daemon, not the bridge, chooses the environment and cwd, serializes installations, supervises the process, streams output and re-asks the bridge for fresh status to verify. Raw arguments never reach a product client.
AI services #
bb's helper inference (thread titles, commit messages: a prompt and a JSON Schema in, a structured value out) and voice transcription are plugin-served. Register in server.ts and implement the contract in the host entry:
// server.ts
bb.experimental_aiServices.register({ id: "acme", displayName: "Acme AI", kinds: ["inference", "voice"] });
// host.ts
import { experimental_aiServicesHostContract } from "@get-bb/plugin-sdk/ai-services";
import { experimental_defineHostEntry } from "@get-bb/plugin-sdk/host";
export default experimental_defineHostEntry({
contract: experimental_aiServicesHostContract,
handlers: {
"ai.inference.complete": async (input) => {
// input: { serviceId, model, reasoningEffort: "none", prompt, outputSchema, timeoutMs }
return { ok: true, model: input.model, value };
},
"ai.voice.transcribe": async (input) => {
// input: { serviceId, model, audioBase64, mimeType, filename, prompt, timeoutMs }
return { ok: true, model: input.model, text };
},
},
});
- The user chooses with
BB_INFERENCE/BB_TRANSCRIPTIONset to<serviceId>/<model>(bb settings ai-serviceslists the options); core calls the registered plugin on the primary host. Every call carriesserviceId, so one entry may serve several ids. - Report failures in the result, never by throwing:
{ ok: false, code, message }withcodeone oftimeout,rate_limited,service_unavailable(core retries and falls back toBB_INFERENCE_FALLBACK),auth_required,request_failed,invalid_response(surfaced). - Reserved ids: the ones the server serves itself (
openaitranscription, the builtin inference providers) route server-direct before the registry andregisterrefuses them. A cross-plugin id collision fails the later plugin's load. The registration needs abb.hostentry; a host artifact may export a provider bridge and default-export the host entry at the same time, which is what the Codex plugin does. When that entry is declared but fails to build,registerdoes not throw: the service is staged unbound with the build problem, the plugin load fails on that problem after the factory has run, and a provider the same factory declared stays listed as unavailable with the build error instead of vanishing from the picker; the unbound service is neither listed nor callable, and a fixed build brings both up on reload.
Strings, icons, branding #
- Strings. Core surfaces (usage banners, sign-in hints, the mobile picker, the agent guide) render the declared
stringsinstead of keying copy on a provider id:signInHint,expiredHint,installUrl, optionalbrandPrefix,planModeCopy,iconTint. - Icon. A plugin-relative SVG (
"./icons/agent.svg") is snapshotted at registration, served from/api/v1/system/providers/<id>/logoaslogoUrl, and drawn as acurrentColormask, so a monochrome mark follows the theme and the declared tint with no frontend bundle; a full-colour logo renders as a silhouette. One of your declared icons ("<pluginId>/<name>", below) is snapshotted the same way and projects to the samelogoUrl; the plugin id must be yours and the name declared, else the plugin fails to load. A glyph name ("Zap") arrives asicon.glyph. Clients draw a vendored brand mark first, thenlogoUrl, then the glyph, then the display name's initial. A frontend can still registerapp.slots.experimental_providerIcon({ providerId, icon })for an inline React mark; none of the bundled providers do, because an icon-only bundle costs fetches at every boot. - Plugin branding. The manifest's
bb.branding.iconis the plugin's own mark: a host glyph name or a plugin-relative SVG path, and never a namespaced glyph (the manifest schema refuses"<pluginId>/<name>"there, failing the build and the load with the value named). When it is a host glyph name it is also the fallback glyph for a bb tool that declares no presentation icon; otherwise that fallback isToolbox. - Order and default. Picker order and the default provider are user settings; the initial order is plugin install order, bundled plugins first.
Plugin icons #
Host glyphs cover most rows. When your provider has a row no host glyph fits, ship the SVG yourself and name it. experimental
The manifest map
"bb": {
"branding": {
"icon": "Zap",
"experimental_icons": { "receipt": "./icons/receipt.svg" }
}
}
- A declared name is lowercase letters, digits and
-, starting with a letter or digit, at most 48 characters. A plugin declares at most 64 icons. - Each value is a plugin-relative path starting with
./and ending in.svg; the file must exist inside the plugin directory (a symlink that escapes it is refused) and be at most 32 KiB. - The map is validated by
bb plugin buildand again when the server loads the plugin: the grammar, the file, the byte cap and the SVG contents. Every failure names the icon (bb.branding.experimental_icons["receipt"] …) and fails the build or the load, exactly as a badbb.branding.icondoes. - Changing the map takes effect on
bb plugin reload, like the rest of the branding.
Naming an icon
Reference an entry by its namespaced glyph, "<pluginId>/<name>", the same split as an extension kind. Host glyph names are PascalCase without a /, so the two vocabularies cannot collide. The form is accepted in three places:
- A row's
presentation.icononitem.openanditem.close:{ glyph: "echo-provider/receipt" }. - A bb tool's
presentation.iconatbb.agents.registerTool. A glyph that names another plugin or an undeclared name rejects the tool registration. - The provider declaration's
iconatbb.providers.register, beside the two formsbb.branding.icontakes. A foreign plugin id or an undeclared name fails the plugin load.
It is refused in one place: bb.branding.icon itself. That field is the plugin's own mark, and a self-reference would only restate a path already in the map while no reader of bb.branding.icon resolves the map, so the manifest schema rejects the namespaced form rather than carrying a name that resolves nowhere.
What ingest does
The server checks every namespaced glyph on every event that carries a full item (item/started, item/completed, and the thread-scoped item/delegation/progress, item/delegation/completed, item/backgroundTask/progress and item/backgroundTask/completed snapshots, the only place a background delegation's or task's final glyph appears, because the assembler stamps the close's presentation on them) before the event is persisted, because a client resolves the name against the plugin inventory, and a glyph that names another plugin or a name you never declared would persist a row whose icon can never be found. The rule is the one at registration: the plugin id must be the emitting plugin's and the name must be in its map. The emitting plugin is the thread's provider plugin, with one exception: a server: "bb" tool row carries the presentation the server resolved from the plugin that registered the tool, so its glyph is checked against that plugin (it passes when the tool is still registered by the plugin the glyph names, with exactly that icon), which is how a tool's own icon survives on any provider's thread.
A glyph that fails gets the same visible fate as an extension payload that fails its schema: the event is replaced, in its batch slot, by a provider/unhandled carrying rawType: "presentation/icon:<itemType>" and { itemId, itemType, glyph, reason } in the raw event, scope and parent kept, so the row is diagnosable where the original would have sat. The reason names the glyph and the plugin: presentation.icon "other-plugin/receipt" is not an icon declared by plugin "echo-provider", or, for a thread whose provider has no live registration, presentation.icon "…" names a plugin icon, but the thread's provider has no live registration to check it against. Host glyphs (no /) are never touched at ingest: whether a client can draw "Zap" is the client's call, and the per-kind fallback covers a name it cannot. The conformance rule presentation/icon-namespaced-declared inspects the same six event types.
How clients resolve it
The installed-plugin inventory (GET /api/v1/plugins, bb.sdk.plugins.list()) carries each plugin's icons: declared name → hashed SVG URL. A row persists the name and follows the plugin's current map at render time:
- A glyph of the form
"<pluginId>/<name>"is looked up in the inventory. Found: the SVG draws. (ASKILL.mdread keeps itsZapover anything the bridge named, as it does for host glyphs.) - Not found, because the plugin is uninstalled or the name is no longer declared: the row's per-kind fallback glyph draws (
Puzzlefor an extension item,Terminalfor a tool, and so on). A host glyph the client's registry knows draws itself; one it does not know falls back the same way.
This is the accepted trade-off: rows are never rewritten when a plugin renames or removes an icon, and a persisted row never depends on a file that may have moved. A disabled plugin's icons still resolve (they ride the plugin's identity, like its compact branding icon); they stop resolving only once the plugin is uninstalled. The resolution happens on the client, before any image is requested, on purpose: a mask whose URL 404s would render nothing, not the fallback.
- Web. The SVG is painted as a CSS mask (
mask-image: url(…)overbackground-color: currentColor), so the shape inherits the row's colour and the presentation'stintstill applies. The tool-use approval banner resolves the same way. - Mobile. The SVG is fetched from the server and rendered as a native SVG view with
currentColorresolved to the row's tint; the host glyph draws while it loads and when the fetch fails. Mobile loads no plugin JavaScript, so this is how a plugin's own artwork reaches the phone.
Ship monochrome shapes: every icon is a tinted mask, and SVG colours are ignored.
The SVG validator
The bytes are served from your installed plugin directory under a strict policy (Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline', X-Content-Type-Options: nosniff), and a monochrome mark never needs scripts, external references, style elements or CSS escapes, so the validator refuses all of them rather than rewriting anything: the served bytes are hashed, and the build and the load must agree on what the plugin ships. It rejects, over and above the rules bb.branding.icon already meets (valid UTF-8 SVG XML, an <svg> root, no doctype, no processing instructions):
- a file over 32 KiB;
- any element outside the SVG namespace, and the elements
script,handler,listener,foreignObject,iframe,image,video,audio,aandstyle(matched case-insensitively); - any
on*attribute; - any
hreforxlink:hrefthat is not a same-document#reference; - any attribute value containing a backslash (a CSS escape such as
u\72l(could spell a loader the literal scan would not see, and browsers run presentation attributes such asfill,filterandmaskthrough the CSS tokenizer, not onlystyle); - any attribute value with a
url(,src(,image(orimage-set(whose target is not a same-document#reference; - a SMIL
attributeNamethat targets anon*handler or anhref; xml:base.
A gradient, a clip path or a marker defined in the same file and referenced as url(#id) is fine. The echo example's icon is the whole pattern: a 24×24 viewBox, fill="none", stroke="currentColor", a few paths.
bb.branding.icon and a path-shaped provider icon keep the older, looser compact-icon validator; whether the stricter one should apply to them too is an open audit question (Compatibility).
Process topology and lifecycle #
- Build.
bb plugin build(or install/reload) bundlesbb.hostinto a self-containeddist/host.js; only Node builtins stay external, and any private@bb/*import is rejected. The server records the artifact by content digest. - Deliver. A thread command for your provider carries
bridgeLaunch { pluginId, source: { kind: "artifact", digest, byteLength }, envPassthrough }to the daemon. The daemon downloads the bytes over an internal route, verifies the digest before caching, and keeps one cache per plugin. Trust is installation trust, identical to every other plugin surface. - Spawn. The runtime spawns one process per provider artifact through the bridge bootstrap (
argv: <bridgeModulePath> <pluginId> <pluginDataDir>). The bootstrap imports the artifact, findsexperimental_providerBridge, refuses anything butexperimental_apiVersion: 1by name, creates the process'stempDir, callsstart({ pluginId, dataDir, tempDir }), wiresonSigterm/onSigint, and feeds stdin lines tohandleLineuntil stdin closes (onClose).dataDiris persistent per plugin on that daemon and survives restarts and plugin updates;tempDiris removed when the process exits. Changingexperimental_bridgeOptionschanges the process identity, so the next command uses a fresh process. - Environment. The bridge's environment is built by the runtime from an allowlist: inherited
BB_*variables are stripped except those the declaration'senv.passthroughnames. Build your children's environments the same way (sanitizeInheritedChildProcessEnv,withoutBridgeRuntimeEnv,buildShellEnvOverrides) and never leak your own inherited env downward. - Children. Bridges may spawn provider processes underneath themselves (the Codex bridge supervises per-thread app-server children; the ACP bridge launches the agent command per session); process topology is bridge-internal and invisible to the runtime. Own the exit-race lessons: finalize on
closenotexitwith a bounded grace, verify currency in stream callbacks, and never let a descendant holding an inherited pipe inject into a fresh session. Read a JSON-lines child withexperimental_readBoundedLines, the LF-only bounded reader the daemon reads your own stdout with, rather thanreadline. - Stop and restart.
thread/stopreleases per-thread resources (see the stop rule above). ArestartRecommendedhint makes the runtime stop the process and resume the thread on a fresh one. The runtime backstops an accepted turn that never starts with a visible watchdog event, sends a best-effortthread/stop { release }for a construction that timed out, and sweeps a bridge's process group when the bridge dies unexpectedly. - The host RPC worker is the artifact's other consumer: the daemon starts it lazily for the first host RPC call, stops it after five idle minutes with no active call, watch or retained lease, and restarts it on the next call. It has its own lifecycle, its own process, and the same
dataDirlayout.
Server decides, plugin decides #
| The bb server (product policy) | Your plugin (the provider's truth) |
|---|---|
| Which permission mode a thread runs in, clamped to the host's ceiling; auto-deciding approvals by mode | Which permission modes the agent can run in at all; how the mode maps onto the agent's native settings |
| The instructions, tool list and injected skill roots a session gets | How instructions, tools and skill roots are handed to the agent; whether skills/configure is supported |
| Defaults: the default model when none is chosen, picker order, the default provider | The live model catalog (model/list), the cold-cache fallback, reasoning ladders, service tiers, catalog scope |
Calling deriveProviderOptions on every command and forwarding the result untouched | What goes in providerOptions (memory toggles, subagent switches, a launch spec) and what the bridge does with it |
| Validating extension payloads against declared schemas and holding each kind to its owning plugin's threads; persisting; projecting rows; the interaction lifecycle event (the server is its only author; a daemon-posted one is dropped) | The extension kinds, their schemas, and the presentation of every row |
| Minting every turn and item id (in the runtime's assembler); the turn and item lifecycle; grammar enforcement at intake | The join keys that let the assembler correlate provider-native ids; when a turn opens and settles |
Acting on a recovery kind: unarchive and retry, typed auth error, bridge restart, drop the steer, bounded retry ladder | Diagnosing the failure in the provider's own terms and choosing the kind; healing its own child when it can |
| Polling maintenance through the bridge, serializing and supervising an installation run, verifying the outcome | Whether each maintenance request exists, what health means for this agent, which command installs or updates it |
Routing BB_INFERENCE / BB_TRANSCRIPTION to a registered service and applying retry and fallback policy | Serving the inference or transcription call, and reporting failures with a typed code |
| Building, digesting and serving the host artifact; the daemon verifying and running it | Everything inside the bridge process, including any child processes and their environments |