| name | bb-plugin-authoring |
| description | Write, build, and install bb plugins. Use whenever the task is to create a bb plugin, extend bb itself, or add a bb CLI command, agent tool, background service, settings, panel, mention provider, or other bb surface via a plugin. Covers the entire backend BbPluginApi and the frontend @get-bb/plugin-sdk/app contract with working patterns. |
Authoring bb plugins
A bb plugin is a TypeScript package running in-process inside the bb server.
Its backend entry default-exports a factory that receives the full plugin API
(bb); an optional frontend entry registers React UI inside the bb app; an
optional host entry is bundled and runs as a supervised Node worker on targeted
enrolled hosts. Plugins are full-trust code in every runtime.
Plugins are on by default. Builtin plugins ship with bb; a few sit behind
their own product gates. bb plugin list shows each plugin's status.
Quickstart
bb plugin new hello # scaffolds ./bb-plugin-hello (add --app for a frontend entry)
cd bb-plugin-hello
bb plugin install . # registers the directory in place (--yes to skip the prompt)
bb plugin dev # rebuild app/host bundles + reload on every save
The manifest is package.json:
{
"name": "bb-plugin-hello",
"version": "0.1.0",
"type": "module",
"engines": { "bb": ">=0.9", "bbPluginSdk": ">=0.4.3" },
"bb": {
"name": "Hello",
"description": "A friendly example plugin.",
"branding": { "icon": "Zap" },
"server": "./server.ts",
"app": "./app.tsx",
"host": "./host.ts",
"skills": ["skills"]
}
}
bb.server (required) — backend entry. Path installs load it as
TypeScript directly (no build step); bb plugin build also emits a
self-contained dist/server.js + server.meta.json that git/npm installs
prefer when its SDK major matches, so consumers never need npm or
node_modules. bb.app (optional) — frontend entry compiled by
bb plugin build into dist/app.js + app.css + app.meta.json; path
and git installs build it automatically at install time. Git installs also
run npm install --omit=dev first (so a git plugin may use third-party
packages) and keep node_modules, since bundling cannot inline data files read
at runtime. So every package your source imports that bb does not shim
belongs in dependencies: a build-required package left in
devDependencies makes the plugin uninstallable from git, and unbuildable
after any install that omits dev deps — including the packaged CLI's own,
which runs npm under NODE_ENV=production. devDependencies is for types
and tooling only.
bb.host (optional, singular) — full-trust Node 22 ESM entry bundled into
dist/host.js + source map + host.meta.json. Its owning server entry calls
it through typed host RPC. The daemon downloads it lazily, verifies its
digest, and reuses one worker per plugin generation. Pure JavaScript
dependencies are bundled; host code may use Node APIs such as
child_process, fs, and fetch.
Installing or updating a git plugin needs npm on PATH; checking for
updates does not, because a check reads the manifest and never builds. Path
installs build from dependencies you have already installed.
- Building yourself (CI, or verifying a build without a running bb): add
bb-app to devDependencies and set "build": "bb plugin build".
bb plugin build needs no server, and depending on bb-app@X builds
against exactly that release's shim configuration. bb downloads its build
toolchain on first use, so cache <dataDir>/plugins/toolchain-* in CI.
bb.skills (optional) — relocates the auto-imported skills directories
(default skills/; [] opts out). Every skills/<name>/SKILL.md is
injected into agent threads as the plugin skills tier.
Backend API imports normally stay type-only;
the root runtime exports are defineRpcContract, supplied by BB for shared
schema contracts, and the numeric PLUGIN_CLI_OUTPUT_MAX_BYTES ceiling:
import { defineRpcContract, type BbPluginApi } from "@get-bb/plugin-sdk". Validator imports such as Zod are normal plugin runtime
dependencies (and are bundled by bb plugin build).
On-disk state per plugin: <dataDir>/plugins/<id>/data.db (its SQLite),
secrets/ (secret settings + HTTP token), logs/plugin.log (JSONL,
rotated at 5MB). Settings edits never auto-reload — bb plugin reload <id>
after configuring.
Looking up the exact API
This skill is a guide, not the contract. For an exact signature or a symbol it
does not cover:
bb plugin types, run in the plugin directory (or given its path),
syncs that plugin's SDK surface to the running bb — no server needed. For a
plugin that depends on the npm package it repins the exact
@get-bb/plugin-sdk devDependency to this bb's SDK version (run
npm install after); for an older plugin that still vendors types/*.d.ts
it rewrites those declarations. Either way a cloned or older plugin can be
thousands of lines behind. --check reports a mismatch without writing;
bb plugin build and bb plugin dev keep things in step too.
- Read the bundled declarations — the authoritative surface, ~13,000
lines of readable declarations with doc comments:
- plugins scaffolded by a current bb depend on the npm package, so after
npm install read
node_modules/@get-bb/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts
(bb-plugin-sdk-app.d.ts for frontend symbols and
bb-plugin-sdk-host.d.ts for the host entry);
- plugins scaffolded before that still carry the root declaration in
types/bb-plugin-sdk.d.ts (plus types/bb-plugin-sdk-app.d.ts for an
app), which the plugin's tsconfig.json maps
@get-bb/plugin-sdk onto. Read whichever the plugin in front of you has.
That layout still works for existing entries, but migrate before adding
bb.host so the /host and /testing/host subpaths are present; bb plugin migrate converts such a plugin to the npm package (it prints the plan
and asks first, and needs --yes when stdin is not a terminal). Never
migrate a plugin the user did not ask you to migrate.
git clone --depth 1 https://github.com/get-bb/bb for host behavior or
a reference implementation: packages/plugin-sdk/src/,
apps/server/src/services/plugins/, plugins/.
Never answer an API question from a built bundle — dist/*.js and the bb app's
own JavaScript are minified. If you are grepping minified JavaScript, go back
to step 1.
Distributing a plugin
Users can install third-party plugins directly from a local path, npm package,
or Git repository:
bb plugin install ./bb-plugin-notes
bb plugin install npm:bb-plugin-notes@^1.0.0
bb plugin install https://github.com/acme/bb-plugin-notes
bb plugin install git:https://github.com/acme/bb-plugin-notes.git@main
bb plugin install git:https://github.com/acme/bb-plugin-notes.git@^1.2.0
A bare HTTP(S) repository URL tracks its default branch. Use the git: form
with an explicit branch, tag, or commit when that tracking intent matters.
Releasing a git plugin with semver tags
Tag each release vX.Y.Z and users can install a range instead of a ref:
bb reads the repository's tags, installs the highest release the range allows,
and bb plugin update moves them to later releases in the same range.
Prereleases stay out unless the range names one. Give each plugin of a
multi-plugin repository its own tag prefix — notes/v1.2.3 — and users add
--tag-prefix notes/.
bb records the tag it installed together with the commit that tag pointed at,
and refuses the plugin if that tag is ever moved to another commit. Publish a
fix as a new version rather than retagging.
Several plugins in one repository
Keep each plugin in its own directory with its own package.json, then index
the directories in a .bb/plugins.json collection manifest at the repository
root:
{
"$schema": "https://getbb.app/schemas/plugins.schema.json",
"schemaVersion": 1,
"name": "acme-plugins",
"plugins": [
{ "name": "notes", "source": "./plugins/notes" },
{ "name": "status", "source": "./plugins/status" }
]
}
Each source is a repository-relative directory that starts with ./. The
file is an index only — it never overrides a plugin's identity, branding,
entry points, or engine ranges. Users install one plugin at a time:
bb plugin install git:https://github.com/acme/bb-plugins.git@main --plugin notes
bb plugin install git:https://github.com/acme/bb-plugins.git@main --subdirectory plugins/notes
bb plugin install path:. --plugin notes
--subdirectory works without a collection manifest; --plugin resolves an
entry name from it. If the repository is not itself a plugin, an install with
neither flag fails and lists the entry names.
Publishing your own marketplace
A marketplace is one marketplace.json file. It lists plugins with their
store branding and their npm or git source; it never hosts plugin code, and
installing an entry runs the same install pipeline a direct install runs.
{
"$schema": "https://getbb.app/schemas/marketplace.schema.json",
"schemaVersion": 1,
"name": "acme-plugins",
"displayName": "Acme Plugins",
"description": "Plugins the Acme team maintains.",
"plugins": [
{
"id": "notes",
"displayName": "Notes",
"description": "Keep notes beside a thread.",
"icon": { "url": "./icons/notes.svg" },
"tags": ["notes", "interface"],
"author": { "name"
The schema is strict: an unknown field rejects the whole document, and the
last catalog bb validated keeps serving. name is the marketplace's identity
and must be unique on the user's machine; bb-community is reserved. engines
may narrow a plugin manifest's ranges and never widen them. Icons are .svg,
.png, or .webp, either an absolute https URL or a path relative to the
manifest — bb fetches and validates them server-side and serves them from its
own origin.
Host it three ways, and users add whichever fits:
bb marketplace add https://plugins.acme.dev/marketplace.json
bb marketplace add git:github.com/acme/bb-marketplace@main
bb marketplace add path:/work/acme-marketplace
An https marketplace is re-read with a conditional request; a git one is
cloned into a throwaway checkout each refresh, with marketplace.json and any
relative icons read from the repository root. Prefer git tag ranges over
pinned refs so a release reaches users without a catalog change. Before
installing from a marketplace that is not bb-community, bb resolves and shows
the true source — including the exact release tag and commit a range lands
on — so keep your listed URL, subdirectory, and range honest.
BB's own official plugins are separate: inclusion in the bb-community
marketplace is a BB release decision, not part of the plugin authoring
workflow, and the bundled official plugins ship inside the app itself and
install from that local copy with no network fetch.
The backend factory
import type { BbPluginApi } from "@get-bb/plugin-sdk";
export default async function plugin(bb: BbPluginApi) {
}
The factory runs at load/reload/enable (time-boxed 30s). A throwing initial
factory puts the plugin in error status with the message as the detail; a
throwing reload candidate leaves the prior registration set running and
reports the reload failure in its detail. bb.pluginId is the plugin's own id.
Keyed registrations must be unique within one factory execution: duplicate
settings, routes, rpc methods, services, schedules, CLI registrations, tools,
instruction providers or mention providers are rejected.
Listeners are different: bb.events.on, settings onChange, and onDispose
are additive, so registering multiple listeners is supported.
bb.log
bb.log.debug|info|warn|error(message: string) — goes to the server log
(prefixed [plugin:<id>]) and to the per-plugin JSONL file behind
bb plugin logs <id> [-n N] [-f].
bb.settings
bb.settings.define(descriptors) declares plain-data descriptors (rendered
in Extensions → Plugins and editable via bb plugin config <id> set <key> <value>). Four descriptor types:
const settings = bb.settings.define({
apiKey: { type: "string", label: "API key", secret: true },
teamKey: { type: "string", label: "Team", default: "" },
mode: {
type: "select",
label: "Mode",
options: ["fast", "slow"],
default: "fast",
},
verbose: { type: "boolean", label: "Verbose", default: false },
project: { type: "project", label: "Project" },
});
const { apiKey, teamKey } = await settings.get();
settings.onChange((next, prev) => {
});
Typing rule: a descriptor with default yields a non-optional value
from get(); without one the value is string | boolean | undefined — so
give non-secrets defaults and handle missing secrets explicitly.
bb.storage
bb.storage.kv — namespaced JSON key-value rows in bb.db:
get<T>(key), set(key, value), delete(key), list(prefix?). Values
are capped at 256KB each — kv is for cursors, links, and small state;
caches and datasets go in the plugin database.
bb.storage.database() — the plugin's own better-sqlite3 database at
<dataDir>/plugins/<id>/data.db (WAL, busy_timeout 5000). Handles are
host-tracked and closed on reload; a closed handle throws.
bb.storage.migrate(db, statements) — statement index = migration id;
unapplied statements run in one transaction. Append-only: never
reorder or edit shipped statements, only push new ones.
const db = bb.storage.database();
bb.storage.migrate(db, [
`CREATE TABLE IF NOT EXISTS issues (id TEXT PRIMARY KEY, title TEXT NOT NULL)`,
]);
bb.server
Read-only facts about the running server. bb.server.loopbackBaseUrl is the
server's own loopback base URL (e.g. http://127.0.0.1:38886), which serves
the SPA + /api + /ws — for plugins that proxy or relay traffic back to
the server itself (the builtin connect plugin's tunnel is the canonical
user). Bind-gated like bb.sdk: reading it before the server is
listening throws, so prefer reading it from handlers, services, and timers.
bb.hosts
For a plugin with a singular bb.host entry, define one runtime contract
shared by the server and host modules:
import {
defineRpcContract,
type ExperimentalHostSignals,
} from "@get-bb/plugin-sdk";
import { z } from "zod";
export const hostContract = defineRpcContract({
setEnabled: {
input: z.object({ enabled: z.boolean() }).strict(),
output: z.object({ enabled: z.boolean() }).strict(),
},
});
export const hostSignals = {
changed: {
payload: z.object({ reason: z.string() }).strict(),
},
} satisfies ExperimentalHostSignals;
The host entry default-exports its implementation:
import { experimental_defineHostEntry } from "@get-bb/plugin-sdk/host";
import { hostContract, hostSignals } from "./contract.js";
export default experimental_defineHostEntry({
contract: hostContract,
experimental_signals: hostSignals,
handlers: {
setEnabled: async ({ enabled }, context) => {
await setEnabled(enabled, context.signal);
await context.experimental_emitSignal("changed", {
reason: "setting-applied",
});
return { enabled };
},
},
dispose: async () => closeChildren(),
});
The server factory calls only its own host entry:
const host = bb.hosts.experimental_client({
contract: hostContract,
experimental_signals: hostSignals,
});
const result = await host.call(
"setEnabled",
{ enabled: true },
{ hostId, signal },
);
const unsubscribeWorkerExit = host.experimental_onWorkerExit(({ hostId }) => {
});
const unsubscribeChanged = host.experimental_onSignal(
"changed",
({ hostId, payload }) => {
},
);
Create the client and register signal handlers in the factory, but call host
methods only after registration completes — from an RPC/event handler,
background service, or timer. Candidate-time calls are rejected because that
generation is not active or fetchable yet.
context.signal aborts one call. context.lifecycle.signal aborts the whole
worker process on idle eviction, reload, disable, uninstall, or daemon
shutdown. Close timers, sockets, and child processes from the lifecycle signal
and dispose.
context.experimental_paths.dataDir is persistent and scoped to this plugin on
the targeted daemon; tempDir is deleted with the worker process.
context.experimental_watch(options, listener) uses the daemon's native file
watcher. Deliveries are coalesced and serialized while the listener is busy;
on rescan-required, reread current state instead of trusting prior events.
Subscriptions are disposed with the worker and can also be disposed directly.
Active calls and native watches automatically keep the worker running. For
independent background work, acquire a lease during a handler with
context.experimental_retainWorker() and dispose it when that work stops.
Lease disposal is idempotent.
Host signals are schema-validated, private to the plugin that owns the host
entry, and ephemeral. Use them as invalidations or progress notifications, not
as durable state; the server callback receives the authenticated hostId.
V1 calls still target only an explicit enrolled host. If a method operates on
an environment or directory, resolve it with bb.sdk and put the needed id or
absolute path in that method's typed input. Core does not infer an environment,
cwd, or lock for host RPC.
The worker is lazy and reusable; there is no short-/long-lived manifest flag.
After five minutes with no active call, native watch, or retained lease, the
daemon gracefully stops it. A later call starts it again. This idle stop does
not emit experimental_onWorkerExit. A crash fails in-flight calls, emits
experimental_onWorkerExit to the active server generation, and a later call
starts a fresh worker. Graceful reload, disable, uninstall, and daemon shutdown
do not emit it. The event is ephemeral, so long-lived plugins must also
reconcile when their target host reconnects. On reconnect, the daemon keeps
workers whose generation is still active and disposes generations disabled or
replaced while it was offline. There is no global worker-count limit. Host code
receives the normalized user PATH without daemon-owned BB_* variables.
These single-worker, idle-eviction, retention, and call-timeout rules describe
the host RPC consumer only. Another daemon subsystem may attach the same
bb.host artifact through a different bootstrap and own a separate process
lifecycle.
Host production code may import public @get-bb/plugin-sdk entrypoints, Node
APIs, and ordinary third-party dependencies. It must not import private
monorepo packages such as @bb/domain, @bb/host-workspace, or any other
@bb/* package; the host artifact build rejects those imports anywhere in its
dependency graph, including type-only imports and relative paths that resolve
into a private package. Keep shared contract types plugin-local and validate
them at the RPC boundary.
Keep @get-bb/plugin-sdk in exact devDependencies, not production
dependencies. The host builder supplies its small runtime helpers and bundles
them into the self-contained artifact, including for managed Git installs that
omit dev dependencies. The daemon never resolves the SDK or private BB
packages from the plugin at runtime.
Pure JavaScript dependencies are bundled. For external tools, use
child_process to probe or invoke tools on PATH. bb V1 provides no
privileged package installer; a plugin that invokes a system installer owns
user consent, elevation, platform-specific behavior, and recovery.
The rest of bb.hosts controls shared loopback port exposure.
Control-plane declarations for host-local daemon behavior. Use
bb.hosts.declareSharedPorts(hostId, ports) to replace this plugin's
desired loopback port set for one host. ports contains integers from 1–65535;
the server deduplicates and sorts them, owns the generation, and delivers the
resulting set to the daemon. The call fails with an actionable error if the
host has no bb connect machine enrollment.
Call await bb.hosts.ensureSharedPortTunnel(hostId) to lazily assign and read
the host's { label, baseDomain } for constructing public URLs. The enrolled
daemon derives both from its trusted gate; plugins cannot choose a domain or
send tunnel identity toward a credential-bearing daemon connection.
Declarations are load-scoped: reload, disable, or shutdown clears them after
the plugin's own dispose hooks run. Plugins do not receive daemon streaming or
socket primitives. Add streaming only for a use case that cannot use bounded
calls, pagination, and lossy invalidation signals.
const tunnel = await bb.hosts.ensureSharedPortTunnel(hostId);
bb.hosts.declareSharedPorts(hostId, [3000, 4173]);
const url = `https://${tunnel.label}--3000.${tunnel.baseDomain}`;
bb.sdk
The full bb SDK bound to this server over loopback — threads, projects,
providers, etc. Bind-gated: reading bb.sdk before the host binds it
throws. The real server binds it before loading plugins, so it is available
from the moment factories run there — but isolated harnesses may not, so
prefer using it from handlers, services, timers, and event handlers for
portability.
bb.sdk.projects.list() preserves the ordinary-project-only default. Plugins
that need the singleton personal project use
bb.sdk.projects.list({ includePersonal: true }).
Area map. Every area below is reachable from bb.sdk. This lists the
methods, not their arguments — read the bundled bb-plugin-sdk.d.ts for exact
signatures (see "Looking up the exact API").
| Area | Methods |
|---|
threads | list get search spawn fork send update delete stop compact wait open output timeline conversationOutline promptHistory archive archiveAll unarchive pin unpin reorderPinned markRead markUnread childSummary paneAction timelineTurnSummaryDetails storageFiles storagePaths cancelPlan clearGoal defaultExecutionOptions; sub-areas events (list wait), interactions (get list cancel resolve respond), queuedMessages (create list update delete send reorder setGroupBoundary), tabs (get update) |
threadSections | list create update delete |
projects | list get create update delete reorder paths ; sub-areas ( ), ( ) |
Prefer your own bb.settings and bb.storage over sdk.system and
sdk.plugins for your plugin's own configuration. The system and plugins
areas write app-wide state that the user owns.
const thread = await bb.sdk.threads.spawn({
projectId,
environment: { type: "project-default" },
prompt: "Work on this issue…",
title: "ENG-42: fix the flaky test",
visibility: "hidden",
});
threads.spawn takes prompt (a string) or input (structured prompt
inputs) — never both. Attribution is auto-filled: origin: "plugin" and
originPluginId: <your id> unless you set them. bb.sdk.threads.send({ threadId, mode: "auto", input: [...] }) starts a turn on an idle thread or
queues/steers a running one.
Read and edit existing threads with the same area — you do not need a
sidebar panel or a spawned thread to reach them:
const { threads } = await bb.sdk.threads.list({ projectId, limit: 50 });
const thread = await bb.sdk.threads.get({ threadId });
const timeline = await bb.sdk.threads.timeline({ threadId });
await bb.sdk.threads.update({ threadId, title: "Fix the flaky test" });
threads.list filters on projectId, parentThreadId, sourceThreadId,
sectionId, originKind, originPluginId, archived, unsectioned,
hasParent, and includeHidden, and it pages with limit and offset.
threads.update writes title, sectionId, parentThreadId, model,
reasoningLevel, and visibility. Use threads.timeline (or
threads.output for the last assistant text) to read a thread's messages.
For raw history, threads.events.list defaults to ascending order and supports
exclusive afterSeq / beforeSeq cursors, order: "asc" | "desc", and a
non-empty typed types array. Combine order: "desc" with beforeSeq to page
backward from the newest matching events without reading unrelated payloads.
Use visibility: "hidden" for background workers. Hidden threads stay
out of sidebar organization and do not contribute unread/pending favicon
attention. They otherwise retain ordinary
list, search, prompt-history, section, lifecycle, parent-operation, direct-open,
and direct-ID behavior. A thread you spawn with a parentThreadId inherits the
parent's visibility when you omit visibility, and a hidden child still
reports its turns and blockers to its parent. This is an organization contract, not a security
boundary: plugins are full-trust server code.
Hidden worker threads need explicit runtime cleanup. Stop each hidden thread
promptly after its final result, including error paths. Stop releases an active,
idle, or stuck runtime and preserves the thread for a later resume. Archive
first when the worker no longer belongs in active lists. Use a finally
block so a plugin failure cannot retain the agent process:
const worker = await bb.sdk.threads.spawn({
projectId,
environment: { type: "project-default" },
prompt: "Review this change.",
visibility: "hidden",
});
try {
await bb.sdk.threads.wait({ threadId: worker.id, status: "idle" });
return await bb.sdk.threads.output({ threadId: worker.id });
} finally {
await bb.sdk.threads.archive({ threadId: worker.id });
await bb.sdk.threads.stop({ threadId: worker.id });
}
SDK realtime observation stays separate from plugin lifecycle events:
bb.sdk.subscribe({ event, callback, ...selector }) returns an unsubscribe
function. Do not use bb.events.on for SDK entity-change subscriptions.
bb.sdk.terminals is the canonical terminal area. list and create take an
explicit discriminated scope: { kind: "thread", threadId },
{ kind: "environment", environmentId }, or
{ kind: "host_path", hostId, cwd }. The host is always explicit; there is no
primary-host default. Existing-session operations are terminal-ID-only:
get, input, resize, output, rename, restart, and close.
restart closes the old session and creates a shell with the same scope, size,
and title; it returns a new terminal ID and does not replay the original command.
bb.sdk.files reads and writes files on a connected host (not just the
server machine — this is the right primitive when the user's files may live
on another host, and its rootPath confinement + compare-and-swap guard make
it the right save path even locally):
const file = await bb.sdk.files.read({ path: "/home/me/notes/todo.md" });
const saved = await bb.sdk.files.write({
path: "/home/me/notes/todo.md",
rootPath: "/home/me/notes",
content: "# Todo\n",
expectedSha256: file.sha256,
mode: 0o600,
});
if (saved.outcome === "conflict") {
}
hostId is optional everywhere (defaults to the primary/local host).
bb.sdk.files.list({ path, query?, limit? }) is a recursive fuzzy file
listing under a directory. Writes cap at 25 MB and return
{ outcome: "written", sha256, sizeBytes }.
Project prompt attachments use a separate server-managed byte surface. Upload
bytes available to the SDK caller with
bb.sdk.projects.attachments.upload({ projectId, clientFile, filename?, mimeType? }); clientFile accepts Uint8Array, ArrayBuffer, Blob, or a
File-like value (bare bytes/Blob require filename). The SDK sends multipart
bytes and returns the stable uploaded-attachment DTO whose relative path can
be used in localFile/localImage prompt input. Read an existing attachment
with bb.sdk.projects.attachments.read({ projectId, path }). Image MIME types
cap at 10 MB and other files at 25 MB. There is no attachment list or
per-attachment remove operation.
For filesystem-backed products that need a tree or mutations,
bb.sdk.files.listPaths({ path, includeFiles, includeDirectories, ... })
returns recursive relative paths with their kind. mkdir, move, and remove
apply the same optional hostId routing and rootPath confinement as
read/write. Mutations are not automatically retried; move refuses to replace
an existing destination, and remove requires recursive: true for non-empty
directories.
bb.sdk.files.createPreview({ hostId?, rootPath, ttlMs? }) returns a temporary
path-shaped baseUrl. Append individually encoded relative path segments to
serve browser assets from that confined host root. This is the preferred
transport for plugin images and sandboxed HTML with sibling-relative assets;
preview URLs expire and never reveal the host id or absolute root.
bb.events.on — thread lifecycle events
bb.events.on("thread.created", ({ thread }) => { ... });
bb.events.on("thread.active", ({ thread }) => { ... });
bb.events.on("thread.idle", ({ thread, lastAssistantText }) => { ... });
bb.events.on("thread.failed", ({ thread, error }) => { ... });
bb.events.on("thread.archived", ({ thread }) => { ... });
bb.events.on("thread.deleted", ({ thread }) => { ... });
Exactly six events. thread.active fires when an applied lifecycle
transition enters the running active state. thread.archived fires after a
thread is archived, including cascade archives (archiving a parent archives
its children too, each with its own event). Observe-only handlers run
fire-and-forget after the transition and can never block or veto it. thread
is the same DTO GET /api/v1/threads/:id serves. Errors are caught, logged,
and counted in the plugin's handler stats (bb plugin list).
Lifecycle events are broadcast to all loaded plugins regardless of sidebar
visibility.
thread.created fires on row creation, so the first user message is not
always in the timeline yet. To react to a thread's content, listen on
thread.active or thread.idle, then read the messages with
bb.sdk.threads.timeline. Because handlers are fire-and-forget, work you do
in a handler — including bb.sdk.threads.update({ threadId, title }) —
cannot delay or interrupt the thread's turn.
bb.http — HTTP routes
bb.http.route(method, path, handler, { auth? }) mounts an exact-match
route (no params/wildcards) at /api/v1/plugins/<id>/http/<path>. The
handler is a Hono handler: (context) => Response | Promise<Response>.
Auth modes:
"local" (default) — request must come from a local bb app origin.
Right for anything the bb frontend calls.
"token" — requires the per-plugin token (bb plugin token <id>;
--rotate generates a new one, invalidating the old) via the
x-bb-plugin-token header or ?token=. Right for external scripts
and machines you control.
"none" — no checks. ONLY for webhooks that verify their own signature
(e.g. Slack's x-slack-signature HMAC) inside the handler.
bb.rpc — the frontend data plane
Define method names plus runtime input/output schemas once, then register
handlers against that contract. Schemas use validator-neutral Standard Schema
v1, which Zod 4 implements directly. The host validates input before invoking
the handler and output before serialization; handler parameters and return
values are inferred from the schemas.
import { defineRpcContract, type BbPluginApi } from "@get-bb/plugin-sdk";
import { z } from "zod";
export const rpcContract = defineRpcContract({
listIssues: {
input: z.object({ filter: z.string().optional() }).strict(),
output: z.object({ issues: z.array(z.object({ id: z.string() })) }),
},
status: {
input: z.null(),
output: z.object({ ready: z.boolean() }),
},
});
export default function plugin(bb: BbPluginApi) {
bb.rpc.register(rpcContract, {
listIssues({ filter }) {
return { issues: listCachedIssues(filter) };
},
status() {
return { ready: };
},
});
}
In app.tsx, import only the backend contract's type. The backend module and
its dependencies are erased from the frontend bundle:
import { useRpc } from "@get-bb/plugin-sdk/app";
import type { rpcContract } from "./server";
function IssuesButton() {
const rpc = useRpc<typeof rpcContract>();
async function loadIssues() {
const { issues } = await rpc.call("listIssues", { filter: "open" });
return issues;
}
return <button onClick={() => void loadIssues()}>Load issues</button>;
}
The wire envelope is { ok: true, result } or { ok: false, error }.
Failures use stable codes: invalid_json, invalid_input, handler_error,
invalid_output, non_json_result, and unknown_method; validation failures
also carry normalized { message, path? }[] issues. Unknown methods return
404, invalid JSON/input returns 400, and handler/output/serialization failures
return 500. Results must be strict JSON values: cyclic objects, bigint,
undefined/functions, class instances, symbol keys, and non-finite numbers are
rejected rather than coerced or silently dropped.
bb.realtime
bb.realtime.publish(channel, payload) broadcasts an ephemeral
plugin-signal WS message to every connected client; the frontend hook
useRealtime(channel, handler) receives it. Payload must be
JSON-serializable; nothing is persisted. Publish state-changed signals and
let the frontend refetch via rpc.
bb.background — services and schedules
bb.background.service("worker", {
async start(signal) {
while (!signal.aborted) {
await doWork();
await sleep(60_000, signal);
}
},
});
bb.background.schedule("sync", "*/5 * * * *", async () => {
await syncNow();
});
- A service starts after the factory completes and must resolve when
signal aborts (reload/disable/shutdown). A crash restarts it with
capped exponential backoff.
- A schedule is a 5-field cron (server-local time) backed by a durable
row keyed (pluginId, name) — it survives server restarts, and the sweep
claims due rows with a compare-and-swap, but it only fires while the
plugin is loaded.
- Semantics differ on throw: a service throwing
NeedsConfigurationError
transitions the whole plugin to needs-configuration and stops
restarting until the next load; a schedule throw (any error) only lands
in the schedule's last_status/last_error shown by bb plugin list.
NeedsConfigurationError is matched by name, so no runtime import is
needed: throw Object.assign(new Error(msg), { name: "NeedsConfigurationError" }). Pair it with bb.status.needsConfiguration
in the factory so an unconfigured plugin reports itself instead of
crash-looping:
const initial = await settings.get();
if (!initial.apiKey)
bb.status.needsConfiguration(
"Set apiKey with `bb plugin config <id>`, then reload.",
);
bb.cli — an agent-facing bb subcommand
One top-level command per plugin; a second register in one factory
execution is rejected.
Users and agents run bb <name> … like any core command; the bb CLI
proxies it to the server, where run executes.
bb.cli.register({
name: "weather",
summary: "Weather lookups",
commands: [
{
name: "today",
summary: "Today's weather",
usage: "bb weather today <city>",
},
],
async run(argv, ctx) {
return { exitCode: 0, stdout: "sunny" };
},
});
Agents discover plugin commands through the server-generated
plugin-commands skill, which lists each command's summary and the
commands usage lines — fill both in. Combined stdout and stderr must fit
PLUGIN_CLI_OUTPUT_MAX_BYTES from @get-bb/plugin-sdk (1,048,576 UTF-8 bytes).
The host rejects a larger result atomically as plugin_cli_output_too_large;
it never clips it. Page growing collections, cap verbose fields, and use
file/streaming commands for large content. Caveat: under the workspace
sandbox (Accept Edits / Approve for me), Claude's macOS sandbox permits
loopback, so bb CLI calls (including plugin commands) work sandboxed;
Linux and other provider sandboxes may still block loopback, in which case
those calls need escalation approval.
Multi-machine rule: run executes on the server, so a path argument names
a file on the INVOKING machine, not on run's filesystem. Never open a
ctx.cwd-relative or user-supplied path with node:fs — on an enrolled
remote machine that silently reads or writes the wrong host's disk. Instead
resolve the invoking host (ctx.threadId → bb.sdk.threads.get →
environmentId → bb.sdk.environments.get(...).hostId, with an explicit
--machine-style flag as the no-thread escape hatch; undefined targets the
server's own host) and do all such file I/O through bb.sdk.files with that
hostId. Reference implementations: the docs plugin's pull/push sync and the
tasks plugin's attachment commands. node:fs remains correct for genuinely
server-local data such as files under the plugin's own data directory.
bb.ui.requestInput — replace the composer with a blocking plugin form
Use bb.ui.requestInput({ threadId, rendererId, title, payload, timeoutMs? }, { signal? }) when plugin backend code must wait for sensitive or structured
user input. The promise resolves to { outcome: "submitted", value } or
{ outcome: "cancelled", reason }. Payloads and responses are JSON values
capped at 64 KiB; response values are delivered only to the waiting plugin
invocation and are never persisted. Pair rendererId with a frontend
pendingInteraction slot. Pass a CLI handler's ctx.signal so disconnecting
the caller cancels the request.
bb.agents — native tools and conditional session configuration
To give agents standing knowledge (conventions, workflows), ship a
skills/ directory. For schema'd capabilities, register a native tool.
For a short, per-resolution instruction block (e.g. "the user is viewing
bb remotely — share tunnel URLs"), use contributeInstructions:
import { z } from "zod";
bb.agents.registerTool({
name: "docs_search",
description: "Search the bundled docs.",
instructions: "Prefer docs_search over guessing conventions.",
experimental_statusLabels: {
pending: "Searching bundled docs",
completed: "Searched bundled docs",
},
parameters: z.object({ query: z.string().min(1) }),
async execute({ query }, { threadId, projectId, signal }) {
return excerpts.join("\n");
},
});
bb.agents.configure((context) => ({
tools: context.provider. === ? [] : [],
: context.. === ? [] : [],
: ,
}));
bb..( {
(!()) ;
;
});
parameters is a zod schema (zod 4; validated per call — bad model args
become a tool error, not a plugin crash) or a plain JSON-schema object
(execute then receives raw unknown). Tool-set changes apply on the NEXT
session start, not mid-session. Name collisions: within one factory execution
duplicate registrations are rejected; across plugins the earlier plugin wins
and yours is dropped with the reason in your status detail.
experimental_statusLabels is optional and supplies static, concise labels
keyed by BB's timeline row status (pending, completed). Each label is
limited to 80 characters; a longer label rejects the registration. BB snapshots the
labels into each plugin tool-call event; it is not a frontend bundle hook. A
status with no label — error, interrupted, or awaiting approval — falls back
to BB's standard Running tool … / Ran tool … wording, as does omitting the
field entirely.
contributeInstructions is synchronous and runs on the thread-start
path — keep it cheap. Prefer skills/ for standing knowledge; use this
only when the text must reflect live plugin state at resolution time.
Ordering is standard BB instructions, selected tools' static snippets,
contributeInstructions output, configure dynamic instructions, data-dir
user instructions, then workspace instructions. Tool snippets are rejected at
registration above 4096 characters; each legacy/dynamic callback contribution
is truncated to 4096 characters.
configure is also synchronous and may be registered only once per factory
execution. Its context has required, plain-data thread, project,
environment, host, and provider: { id, model } objects, plus sideChat
and origin: { kind, pluginId }; genuinely absent values are null, not
omitted. tools names and skills frontmatter names may select only this
plugin's static registrations. A tools entry may instead be
{ name, parameters } to override the parameter schema advertised to the
provider for that resolution only — parameters must be a JSON-serializable
JSON-schema object with root type: "object", at most 128 KiB serialized, and
should only narrow what the registered schema accepts, since execution-side
validation still runs the registered parameters. Unknown or duplicate ids,
malformed output, an invalid override, more than 256 ids in either array, or a
throwing callback fail closed for that plugin only. Dynamic instructions are
truncated to 4096 characters.
Resolution happens for thread.start and turn.submit. A selected tool set
takes effect only when the provider session is next started/resumed; BB never
hot-mutates a running provider session. Instructions follow the same rule: a
live provider session keeps the instructions it was constructed with, and
changed instructions apply when the session is next constructed.
Skill catalog changes follow the daemon's established runtime policy: a busy
environment keeps its current staged catalog until a safe relaunch. Side chats
evaluate configure with sideChat: true; returned tool, skill, and dynamic
instruction selections apply at those same boundaries. Independent side-chat
safety policy such as permission escalation is unchanged. The legacy
contributeInstructions provider remains excluded from side chats, so use
configure for side-chat-aware dynamic instructions.
bb.agents.experimental_registerProvider — agent providers
A plugin can contribute a full agent provider: a picker entry whose threads
run on a provider bridge the plugin ships. The working reference is
examples/plugins/echo-provider — declaration, bridge, and conformance test
in one small package.
bb.agents.experimental_registerProvider({
id: "echo-agent",
displayName: "Echo Agent",
icon: "./icons/echo.svg",
kind: "agent",
bridge: { entry: "provider-bridge" },
capabilities: {
supportsServiceTier: false,
supportsNativeUserQuestion: false,
fork: "none",
supportsManualCompaction: false,
supportsThreadArchive: false,
supportsThreadRename: false,
supportsWorkflows: false,
permissionModes: ["full"],
reasoningLevels: ["medium"],
},
composerActions: [],
});
The icon. icon takes the same two shapes as bb.branding.icon: a named
host glyph ("Zap") or a plugin-relative SVG path ("./icons/echo.svg"). A
path is served to clients as a logoUrl and drawn through <img>, so its
currentColor cannot follow the bb theme; a glyph name carries no bytes, so
there is no logoUrl at all. For a monochrome mark, ship an app.tsx too and
register the same artwork with
app.slots.experimental_providerIcon({ providerId, icon }) — it renders
inline and inherits the theme. The four first-party provider plugins do
exactly this (plugins/provider-codex/app.tsx).
Ids are collision-rejected against core providers and other plugins'
registrations; registrations replace wholesale on reload like every other
surface. Disabling the plugin removes the provider (open threads show a
provider-unavailable state instead of erroring).
The bridge. A provider bridge ships inside the plugin's bb.host
artifact — the same artifact a host RPC entry ships in, and a plugin may have
both. Export it by name:
import { experimental_defineProviderBridge } from "@get-bb/plugin-sdk/provider-bridge";
export const experimental_providerBridge = experimental_defineProviderBridge({
handleLine(line) {
},
start({ pluginId, dataDir, tempDir }) {},
onClose() {},
onSigterm() {},
});
Do NOT start the bridge yourself: the daemon owns the process boundary (argv,
plugin-scoped directories, bounded stdin framing, signals) and imports this
export out of the artifact. Importing the module must start nothing, which is
also what lets your conformance test drive handleLine in-process.
Everything a bridge compiles against is published at
@get-bb/plugin-sdk/provider-bridge — protocol schemas, the bridge kit, and the event
vocabulary — so add @get-bb/plugin-sdk to dependencies (not just
devDependencies). A bb.host artifact cannot import bb's private @bb/*
workspace packages; an installed plugin could not resolve them.
The bridge speaks the canonical Provider Bridge Protocol — line-delimited
JSON-RPC 2.0 over stdio, documented in docs/provider-bridge-protocol.md.
Minimum correct surface: the initialize
handshake ({protocolVersion, capabilities}), thread/start /
thread/resume answering {providerThreadId} after a thread/identity
notification, turn/start driving the event grammar (turn/input/accepted
→ turn/started → item/started → deltas → item/completed →
turn/completed as thread/event notifications carrying bb
ThreadEvents), thread/stop honoring both intents (release must
fabricate nothing), and reply hygiene: unknown method → -32601, invalid
params → -32602 with the issues, never a silent drop. The bridge — never
the provider — mints every turn and item id, with per-instance entropy so
ids survive restarts and resumes.
Conformance. Ship a test that drives
@bb/provider-bridge-protocol/conformance against your bridge in-process:
export the bridge surface, wire runBridgeConformance with a
transport whose send calls it and whose takeMessages drains captured
stdout, and assert all eleven scenarios pass (see
examples/plugins/echo-provider/provider-bridge.conformance.test.ts).
Delivery. On install/reload the server builds dist/host.js and records
its digest. Thread commands for the provider carry {pluginId, digest} to the
host daemon, which downloads the bytes from the server, verifies the digest
before caching them, and runs the artifact with its own node — it never
executes unverified bytes. It is one cache and one route with the host RPC
worker, because it is one artifact.
Trust model: installation trust, exactly like every other plugin surface. A
bridge runs only for an installed, enabled plugin, and only on hosts whose
server instructs it.
bb.ui — host-rendered UI (no frontend bundle needed)
bb.ui.registerMentionProvider({
id: "issue",
label: "Issues",
triggers: ["@", "#"],
search({ trigger, query, projectId, threadId }) {
return [{ id: "42", title: "ENG-42 Fix flake", subtitle: "Todo" }];
},
resolve(itemId) {
return { context: "# ENG-42…" };
},
});
Thread actions render in the thread header; mention items render under
label in the menu for each registered trigger. All handlers run server-side.
There is deliberately no plugin slash-command surface: the composer's /
menu lists skills, so a plugin capability that crafts a prompt for the agent
ships as a skills/ entry instead.
bb.status
bb.status.needsConfiguration(message) — mark the plugin
needs-configuration (shown in bb plugin list and the UI) instead of
failing. Cleared on the next load.
bb.onDispose and the reload lifecycle
bb.onDispose(hook) registers cleanup; hooks run LIFO. On
reload the host first runs the factory against a candidate registration set.
If it throws, the complete previous set stays live. Once the candidate
succeeds, the host aborts old background services and awaits them (bounded),
runs dispose hooks LIFO (each isolated), drains in-flight http/rpc/event
handlers, closes every storage.database() handle, invalidates the old bb
handle, and replaces the registration set wholesale. Disable/shutdown perform
the same cleanup without a replacement. A
captured bb from a previous load throws PluginContextStaleError on use
— never stash the API object in module-level state that outlives a load.
Frontend (bb.app entry)
app.tsx default-exports definePluginApp from @get-bb/plugin-sdk/app.
React and the SDK are never bundled — bb plugin build shims them to
the host's shared runtime, so the bundle only works inside bb.
import {
definePluginApp,
useRpc,
useRealtime,
useRealtimeConnectionState,
useSettings,
useBbContext,
useBbNavigate,
useComposer,
useComposerView,
} from "@get-bb/plugin-sdk/app";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
export default definePluginApp((app) => {
app.contentScripts.register({
id: "editor-enhancement",
mount({ pluginId, generation, signal }) {
const onKeyDown = (event: KeyboardEvent) => {
};
document.addEventListener("keydown", onKeyDown, { signal });
return () => document.removeEventListener("keydown", onKeyDown);
},
});
app.slots.homepageSection({
id: "issues",
: ,
: ,
});
app..({
: ,
: ,
: ,
: ,
});
app..({
: ,
: ,
: ,
: ,
: ,
: [
{
: ,
: ,
: ,
: ,
: ,
},
],
: ,
});
app..({
: ,
: ,
: ,
: ({ threadId, openPanel }) => {
({ : });
},
});
app..({
: ,
: ,
: ,
: {
({ : });
},
});
app..({
: ,
: [{ : , : }],
: [
{
: ,
: ,
:
composer.(
,
),
},
],
: [{ : , : }],
: {
: [
{
: ,
: ,
:
.(text.(), ({
: match.,
: match. + match[].,
})),
},
],
},
});
app..({
: ,
: ,
});
app..({
: ,
: ,
: ,
: (),
});
app..({ : , : });
app..({
: ,
: ,
: ,
: ,
});
});
A control in the thread header
app.slots.experimental_threadHeaderAction renders a component in the thread
header's action row. It replaced the older backend-only
bb.ui.registerThreadAction, so a control that needs to draw live state (a
count, a cluster, a status) is now the only shape:
app.slots.experimental_threadHeaderAction({
id: "subagents",
title: "Subagents",
component: ({ threadId, projectId, isCompactViewport }) => { ... },
});
The row is a 48px chrome row with 28px controls: render ONE inline control, and
put anything taller in a portalled popover. The host clamps your footprint, so
an oversized control is clipped rather than allowed to break the header. title
names the host's wrapper region — your icon-only button still needs its own
accessible name. A split layout renders one header
per pane, so your component mounts once per visible thread — keep per-thread
state in the component, never in a module-level singleton.
A common pairing with a replaced sidebar: hide child threads from the list and
surface them here instead, filtering experimental_useSidebarThreads() by
parentThreadId === threadId.
Replacing the sidebar thread list
app.slots.experimental_threadList is the one exclusive slot: only one
list fills the sidebar's scroll area. Registering activates the replacement
while the plugin is enabled. If multiple plugins register one, the first in
deterministic slot order is active by default; removing it reveals the next.
The user can pin BB's list or a specific provider under
Settings → Appearance → Sidebar. The choice is per client.
Your component gets the scrolling list and nothing else. The New-thread button,
the search field, the plugin nav rows, and the footer stay host-rendered —
other plugins live in two of those, so a replaced list must not remove them.
Put your own controls at the top of your scroll area instead.
If the chosen plugin is disabled, uninstalled, or its component throws, bb
renders its own list again (plus a toast on a crash), so the sidebar is never
empty.
The component receives:
interface PluginThreadListProps {
activeThreadId: string | null;
activeProjectId: string | null;
isCompactViewport: boolean;
onNavigate: () => void;
searchQuery: string;
experimental_Original: ComponentType;
}
Reading and acting on threads. Two hooks back a replaced list:
const { status, threads, projects } = experimental_useSidebarThreads();
const actions = experimental_useSidebarThreadActions();
const { pullRequest } = experimental_useSidebarThreadPullRequest(thread.id);
actions.open(id, { split: true });
actions.openNewThread({ projectId });
actions.setPinned(id, true);
actions.setRead(id, false);
actions.rename(id, "New title");
actions.archive(id);
actions.requestDelete(id);
Destructive actions deliberately route through the host's own flow, so there
is no silent delete: deletion is recursive, and only bb can show the
confirmation that counts the child threads.
Unit-test a list with renderSlot(...) from @get-bb/plugin-sdk/testing/app:
seed rows with the sidebarThreads option and assert against
inspection.sidebarActionCalls.
Splits. Rows can drag out to the split area:
const { splitProps, isAvailable, layout } =
experimental_useSidebarThreadSplit(thread.id);
<a {...splitProps} onClick={...}>
{title}
{/* layout is data: draw a mini-map, a tint, or nothing */}
</a>;
The host owns the gesture rules, including the one that matters if your list
has its own drag-to-reorder: a split drag engages only once the pointer leaves
the sidebar.
Your row, your menu. This API ships no components. Build your own context
menu from experimental_useSidebarThreadActions — it exposes everything bb's
own menu does, including requestDelete, which opens bb's confirmation.
Keyboard support is a DOM contract. bb's thread shortcuts find rows by
query selector, not by React state. Put both attributes on each row's anchor or
the surface-specific numbered shortcuts, thread.next, and thread.previous
silently stop working:
<a data-sidebar-thread-shortcut-target="" data-sidebar-thread-id={thread.id}>
Trusted frontend content scripts
app.contentScripts.register({ id, mount }) runs ordinary
bundled JavaScript/TypeScript in the bb app shell without a React slot. It is
full-trust, same-origin page code — not a security sandbox. It can access
the app DOM and any authenticated client state available to ordinary page
code, so install only plugins you trust. bb does not use eval, Function,
or persisted source strings: the existing bb.app build emits a normal CSP-
compatible ESM bundle.
The host mounts scripts in registration order after the bundle loads and
definePluginApp setup validates. mount receives
{ pluginId, generation, signal, experimental_setThreadRowStatus? }:
generation is a monotonic per-window mount attempt number, and signal
aborts before cleanup starts. The optional experimental setter targets an
explicit thread row with { icon, label, tone? } or clears it with null.
Use tone: "running" for the host's animated running treatment. The host
scopes statuses to the calling plugin and automatically clears them when that
frontend generation deactivates; feature-detect the setter for compatibility
with older bb clients.
A script may return nothing, a disposer, or a promise of either; async mount
setup is time-boxed to 10 seconds. Keep long-running work outside the returned
promise, observe signal, and catch failures in work the host does not await.
A replacement bundle and setup validate before lifecycle cutover. The host
then aborts and disposes the prior generation before mounting candidate scripts,
so listeners and observers never overlap. If a mount throws or rejects, the
host aborts that candidate, disposes already-mounted candidate scripts in
reverse registration order, and publishes none of its slots or CSS. Import or
setup failure also deactivates stale UI because the corresponding backend may
already have been replaced. Disable, stop, removal, and app-window teardown
follow the same abort-then-reverse-dispose path; every returned disposer is
called at most once. Each desktop window, browser tab, and remote client owns
an independent instance.
Synchronous and awaited asynchronous mount/dispose failures are contained and
logged; they cannot stop sibling plugins from activating. The current
window's last load/setup/mount/dispose failure appears on the plugin Settings
detail page. The host cannot catch a detached promise that plugin code creates
and never returns, so detached work must handle its own errors.
Prefer the existing imported app.css pipeline for static styles. A content
script may create DOM or <style> nodes when behavior genuinely requires it,
but its abort handler/disposer must remove every node, observer, listener,
timer, and class it owns. The context deliberately has no route/project/thread
snapshot yet; use stable SDK hooks inside React slots rather than polling or
installing global navigation observers. Complete cleanup-safe example:
examples/plugins/content-script.
Slot props contracts (versioned, additive-only):
-
homepageSection → { projectId: string | null } (project in view on
the compose surface). Registration: { id, title, component }.
-
settingsSection → {} (deliberately no props in V1). Rendered on the
plugin detail page below the host-rendered declarative settings
form for running, needs-configuration, and degraded plugins. Registration:
{ id, title?, description?, component }; title is an optional host-rendered
section heading and description is optional supporting copy rendered with
that heading. Use the existing hooks (useRpc, useRealtime,
useRealtimeConnectionState, useSettings, useBbNavigate, useBbContext)
for data. Enabled plugins appear in the
settings sidebar when they declare settings descriptors OR register
settings sections.
-
navPanel → { subPath: string } — owns the whole route at
/plugins/<pluginId>/<path>/* and gets its own sidebar entry. subPath
is the route remainder after the panel root ("" at the root), so deep
links like /plugins/notes/notes/work/ideas.md land with
subPath: "work/ideas.md". Navigate within the panel via
useBbNavigate().toPluginPanel(path, { subPath, replace? }) — browser
back/forward then walks panel-internal history (prefer this over hash
routing).
Registration:
{ id, title, icon, path, component, experimental_fixedTabs?, experimental_sidebarAccessory?, headerContent? }.
BB automatically wraps every plugin page in the same host-owned App panel
used by New thread and thread pages. The page component supplies only its
main body; it must not mount a second panel layout or register Browser and
Terminal itself. BB owns the desktop split, compact drawer, header/panel
toggle, resizing, tab strip, persistence, and the shared panel.toggle,
panel.newTab, and terminal.open keyboard commands.
New tab is a transient host launcher. On a plugin page it offers Browser
(when the desktop browser is available) and Terminal; it does not offer