This is the full developer documentation for bb plugin atlas
# bb plugin atlas
> Every surface a bb plugin can occupy, with the API that reaches it, pinned to a released version of bb.
This site explains how bb plugins work and documents every surface a plugin can occupy. **bb** ([getbb.app](https://getbb.app)) is an open “agentic IDE that builds itself”. It runs as a Node server with SQLite as the source of truth, a host daemon on every executor machine, a client application (web, Electron, mobile) and the `bb` CLI. Almost everything visible in its interface and almost everything its agent does is a plugin on one public SDK, and that SDK is the subject of this site.
## Reading path
[Section titled “Reading path”](#reading-path)
Read the first four pages in order. Each one builds on the previous one, from bb as a whole down to the code of one plugin.
1. [How bb works](/bb-plugin-atlas/concepts/): the three processes and the objects bb manages in them.
2. [How a plugin works](/bb-plugin-atlas/architecture/): the three entries, how they talk, where state lives, and what a reload does.
3. [Your first plugin](/bb-plugin-atlas/start/): scaffold a plugin, install it, use it from three places, change it.
4. [Choosing a surface](/bb-plugin-atlas/choose/): from an idea to the surface that fits and a first-party plugin to read.
Then keep the reference open while you build. It is generated from a pinned release of bb, so a page either matches shipped code or fails the build:
* **[Surfaces](/bb-plugin-atlas/surfaces/)**: every place a plugin can appear or act.
* **[Frontend slots](/bb-plugin-atlas/frontend/)**: the registration points an app entry reaches surfaces through.
* **[Backend namespaces](/bb-plugin-atlas/backend/)**: the members of the plugin API a server entry is handed.
* **[UI zone map](/bb-plugin-atlas/map/)**: the same surfaces, numbered in place on the real window.
## The pinned release
[Section titled “The pinned release”](#the-pinned-release)
| | |
| --------- | ------------------------------------------------------------------------------------------------------------------- |
| Version | `@get-bb/plugin-sdk` 0.4.104, application 0.43.3 |
| Release | the shipped tag `desktop-v0.43.3`; every generated link on this site resolves against that commit |
| Contracts | `packages/plugin-sdk/src/backend-contract.ts` · `app-contract.ts` · `host-contract.ts` |
| Count | 23 `BbPluginApi` members · 22 `app.slots.*` methods · 46 surfaces in 7 groups · 36 first-party plugins + 9 examples |
Agents can read the whole site as text: [`/llms.txt`](/bb-plugin-atlas/llms.txt), [`/llms-full.txt`](/bb-plugin-atlas/llms-full.txt), any page at `/.md`, and the brief corpus at [`/briefs.json`](/bb-plugin-atlas/briefs.json).
## How to read this
[Section titled “How to read this”](#how-to-read-this)
Every symbol on this site carries one of two status chips, and only these two carry a colored dot:
* `stable`, emerald: the name carries no prefix, and a breaking change would require a major version.
* `experimental_`, cobalt: the prefix is part of the name itself, and `docs/api_to_audit.md` lists what must be audited before it is dropped.
A card whose members are partly stable and partly prefixed carries both chips. A plain tag without a dot is a label, such as a member name, a slot kind or a surface role, and says nothing about stability.
Every name, path and number links to GitHub at the pinned commit, with the exact line where one is known. The links are generated from `data/symbols.json`, never typed by hand. Identifiers, paths, commands and bb’s own UI strings appear exactly as the repository writes them. Where the research could not confirm a fact, the text says **not verified** instead of guessing.
## Agent brief buttons: what lands on the clipboard
[Section titled “Agent brief buttons: what lands on the clipboard”](#agent-brief-buttons-what-lands-on-the-clipboard)
Every surface, slot, namespace and advanced-point card carries three actions. Each one copies ready context for the agent that will write the plugin.
| Button | What it puts on the clipboard | Where to paste it |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| **Brief for agent** | A Markdown brief: what the capability is, which entry it lives in, the exact registration call, the props type, the slot kind, stability, SDK symbols with paths and lines, which first-party plugin to read, the rules authors trip over, and acceptance criteria | any terminal agent (Claude Code, Codex, Cursor): paste it as the first message of the task |
| **as a bb mention** | Exactly the format the built-in Plugin Guide produces: `text/plain` with `Build a plugin that uses @`, plus `text/html` carrying `data-prompt-mention`. In the bb composer this becomes a real mention of the surface | the bb composer, as long as the `plugin-api-docs` plugin is enabled; otherwise plain text is pasted |
| **add to set** | Collects surfaces into a set; the panel in the bottom right copies **one** brief for several capabilities: shared scaffold, architecture, a block per capability, merged rules and a checklist | a plugin that does several things at once: one request instead of three |
The set survives navigation and a page reload (`localStorage`). The “open in bb” links, including the one to the built-in Plugin Guide, point at a bb running on this machine (`http://localhost:38886`).
# About this site
> What this reference covers, which release it is pinned to, and how it is built.
This site is a reference for authors of bb plugins: what a plugin can own, where each entry runs, what the API surface is, and what the trust model actually guarantees. This page states which release the site is pinned to, how it is built, and the license.
## The pinned release
[Section titled “The pinned release”](#the-pinned-release)
Everything here describes the **shipped release**: bb 0.43.3, `@get-bb/plugin-sdk` 0.4.104, the `desktop-v0.43.3` tag. Generated pages carry that version stamp, and every symbol link resolves against the pinned commit.
Anything that exists on `main` but not in 0.43.3 is labelled “unreleased: lands after 0.43.3” rather than documented as available. Claims are never upgraded from a hedge: where the research could not confirm something, the text says **not verified** and leaves it visible.
## How it is built
[Section titled “How it is built”](#how-it-is-built)
The reference sections (surfaces, backend namespaces, frontend slots, the plugin catalog, the changelog) are generated from the upstream repository at the pinned commit and committed as data, so an upstream release arrives as a reviewable diff. The prose is written by hand. Identifiers, paths, CLI commands, plugin ids and bb’s own UI strings appear exactly as upstream writes them and are never translated.
## Source and contributions
[Section titled “Source and contributions”](#source-and-contributions)
The site is built from [github.com/nobilix/bb-plugin-atlas](https://github.com/nobilix/bb-plugin-atlas). Issues and pull requests are welcome; [CONTRIBUTING.md](https://github.com/nobilix/bb-plugin-atlas/blob/main/CONTRIBUTING.md) describes what is generated, what is written by hand, and how to run the checks.
## License
[Section titled “License”](#license)
The site and its tooling are MIT-licensed. bb itself is a separate project; its code, names and UI strings belong to it.
# Advanced extension points
> What else a plugin can own: the agent, where it runs, the machine itself, utility inference and the browser.
This part covers what else can belong to a plugin: the agent, the place it runs, the machine itself, utility inference and the browser. The guiding principle, repeated throughout the documentation: core owns the minimum, “every special case is a public primitive or is deleted”, and core “never branches on a provider id”.
* [Agent providers and the provider bridge](/bb-plugin-atlas/advanced/providers/): two halves in one artifact, the ACP dialects, and bridge record mode.
* [Environment providers](/bb-plugin-atlas/advanced/environments/): where a thread’s workspace lives; core owns the passage.
* [Machine providers](/bb-plugin-atlas/advanced/machines/): the plugin creates compute, core owns enrollment and the daemon install.
* [Host workers](/bb-plugin-atlas/advanced/host-workers/): your own Node code on an enrolled machine (files, wake lock, processes).
* [AI services](/bb-plugin-atlas/advanced/ai-services/): bb’s utility inference and transcription.
* [Desktop browsers](/bb-plugin-atlas/advanced/desktop-browsers/): a real browser window on a host.
* [Thread plugin metadata](/bb-plugin-atlas/advanced/thread-metadata/): a JSON namespace that travels with the thread.
# AI services
> bb's own utility inference (thread titles, commit messages) and transcription.
AI services let a plugin own bb’s utility inference (thread titles, commit messages) and transcription.
The full walkthrough and the snippet are on the `bb.experimental_aiServices` card in [Backend API](/bb-plugin-atlas/backend/). In short: registration happens on the server, the implementation of `experimental_aiServicesHostContract` lives in the host entry, the user selects through `BB_INFERENCE`/`BB_TRANSCRIPTION`, and core calls the primary host and applies its own retry/fallback policy: timeouts and rate limits fall back to the fallback model, authentication errors do not.
# Desktop browsers
> A real browser window on a host, for automation that needs a logged-in profile.
The desktop browsers namespace gives a plugin a real browser window on the host, for automation that needs a logged-in profile.
```ts
// plugins/browser-automation/server.ts (abridged) — the full cycle
const desktop = bb.sdk.experimental_desktopBrowsers;
const { instances } = await desktop.listInstances({ hostId: selection.hostId });
const instance = instances.find((c) => c.instanceId === selection.instanceId);
const scope = { hostId: selection.hostId, instanceId: instance.instanceId,
generation: instance.generation, threadId: input.threadId };
const result = await desktop.createTab({ ...scope, url: "about:blank", presentation: "hidden" });
const lease = await desktop.acquireControl({ ...scope, tabIds: [result.tab.tabId],
controllerLabel: "Browser Automation", ttlMs, allowPersonal: selection.tabId !== undefined });
const connection = await desktop.openConnection({ ...scope, leaseId: lease.leaseId });
if (connection.hostId !== scope.hostId) throw new Error("Desktop connection returned a different host");
session.expiresAt = Math.min(session.expiresAt, lease.expiresAt, connection.expiresAt);
```
**12 members of the namespace**, verified at the pinned release. `listInstances` · `listTabs` · `createTab` · `acquireControl` · `openConnection` · `releaseControl` · `revealTab` · `closeTab` · `captureTab` · `listImportSources` · `importCookies` · `subscribe`; `subscribe` is “a disposable two-second polling subscription”.
**A peculiarity.** This surface is not in `backend-contract.ts` at all: it is reachable only through `bb.sdk` and has no plugin-contract type.
**Discipline.** `ttlMs` is real (30 minutes in `browser-automation`), and a session’s lifetime must be the `min` of its own TTL, the lease and the connection. `allowPersonal: true` only when the user explicitly picked an existing tab; the plugin remembers what it `owned` so it knows what it may close. A mismatch between the connection’s `hostId` and the scope is treated as fatal.
**Who ships one.** The only consumer is `browser-automation`.
# Environment providers
> Where a thread's workspace lives, the lifecycle edges, claimPath, availability, and worktree specifics.
An environment provider lets a plugin own the place where a thread’s workspace lives, while core owns the passage through it. Below are the lifecycle transitions, then what creation adds, claimPath, availability and worktree specifics.
```plaintext
// docs/lifecycle-diagrams.md — the transitions (source packages/domain/src/environment-lifecycle.ts)
start ──▶ creating
creating ──provision.requested──▶ provisioning
creating ──provision.failed | provision.cancelled──▶ error
creating ──destroy.recorded──▶ destroyed
provisioning ──provision.succeeded | provision.cancelled (workspace on disk)──▶ ready
provisioning ──provision.failed──▶ error
provisioning ──provision.cancelled (no workspace)──▶ destroyed
ready | error ──provision.requested──▶ provisioning
ready | error ──destroy.recorded──▶ destroyed
// A MISSING edge = the event is a no-op in that status (the writer returns illegal-transition)
```
**What creation adds.** Five fields in `environments`: `ownerThreadId`, `attempt`, `claimPath`, `statusMessage`, `pendingLog`. There is no parallel provisioning phase, no attached flag, no transient-failure counter and no automatic retry ladder: creation failures are terminal.
**claimPath.** `experimental_claimPath` returns false if the path on this host is held by another unattached run, if the attempt is no longer in `creating`, or if it has already claimed a different path; repeating the same claim succeeds. “Check for already-attached threads after the claim and before mutating a shared checkout.”
**availability.** Probed in the background for the pickers and again at thread creation, inside the decision timeout, for the selected provider and machine only. **Not verified:** the documents disagree on whether a cached answer may be used at creation time.
**Worktree specifics.** Worktrees live at `/plugins/environment-git-worktree/host-data/worktrees//`; `.worktreeinclude` (gitignore syntax) copies untracked local files after creation and before `.bb-env-setup.sh`; bb copies rather than symlinks, never overwrites what is already in the worktree, and skips symlinks in the source. Archiving the last thread starts a five-minute grace period, not a deletion.
# Host workers
> Your own Node code on an enrolled machine: files, wake lock, processes.
A host worker runs your own Node code on an enrolled machine: files, wake lock, processes. The example below defines one handler and re-exports the provider bridge from the same artifact.
```ts
// examples/plugins/echo-provider/host.ts — one artifact, both halves
import { experimental_defineHostEntry } from "@get-bb/plugin-sdk/host";
export { experimental_providerBridge } from "./src/provider-bridge.js"; // the bridge rides along
export default experimental_defineHostEntry({
contract: echoProviderHostContract,
handlers: {
hostGreeting: (_input, context) => ({
platform: process.platform,
dataDir: context.experimental_paths.dataDir,
}),
},
});
```
**The handler context.** `signal` (aborted when the request is cancelled or the worker is disposed), `lifecycle.signal` (aborted once, for the lifetime of the worker process), `experimental_paths.{dataDir,tempDir}`, `experimental_emitSignal(name, payload)`, `experimental_watch(options, listener)` over the daemon’s native watcher, and `experimental_retainWorker()` with a disposable lease.
**watch.** Events are `{kind:"changed", changes}`, `{kind:"rescan-required"}`, `{kind:"watch-error", message}`; the defaults are `debounceMs` 75 and `maxWaitMs` 500; limits are 4096 changed paths and 1 MiB per batch, 4096 ignore entries, and a path of at most 16 KiB.
**Also in `/host`.** The native-roots contract (`experimental_nativeRootsHostContract`, `experimental_filterResolvedNativeRoots`), the readers of vendor plugin roots (`experimental_resolveClaudePluginRoots`, `experimental_resolveVendorPluginRoots`), `experimental_killProcessesWithCwdUnder` (SIGTERM, then SIGKILL, for every process whose cwd is under a directory), `experimental_sanitizeInheritedChildProcessEnv`, and `experimental_spawnPortableOutputProcess`.
**The progress pattern.** The server hands the host an `operationId` of the form `create#${pathKey}#${attempt}`, the host stamps its signals with it, the server routes every signal to the right `report` and deletes the entry in a `finally`.
**The environment.** A host worker gets “a normalized user `PATH` without the daemon-owned `BB_*` variables”. That is hygiene, not a security boundary. `stdin`/`stdout` are `ignore`, and only stderr leaves, through the daemon’s rate-limited warn logs (16 KiB per line, 1000 lines).
# Machine providers, server access, bootstrap
> The plugin creates compute; core owns enrollment and the daemon install.
A machine provider lets a plugin create compute, and the division of labor with core is strict: “core owns enrollment, the identity files and the internals of installing the daemon”, and “machine plugins do not clone projects”.
Once a new machine is attached, core itself configures the project’s git remote and registers its source, and only then calls the environment provider that needs a `projectCheckout`. A machine is not scoped to a project: “nothing in its creation names a project, and projects reach the machine later through project sources”.
**Order.** `validate`/`availability` → `create` (idempotent by key) → `await checkpoint(resource)` → `bootstrap({ key, executor, report, signal })` → return `{ status:"created", name, resource }`.
**Removal.** `remove({ hostId, resource, … })` when a checkpoint exists; `reconcileCleanup({ key, … })` when it does not: “find and remove indeterminate allocations by the durable key; return failed until the allocation intent is resolved”. Removing a persistent machine cascades through the environment providers first; removing an ephemeral one skips teardown and never resumes suspended compute.
**Idle.** Idle timing is yours, not core’s: `experimental_thread.events` + `experimental_terminal.input` to extend the deadline, and `bb.background.schedule` plus a startup reconciliation to sweep. “The dispatch hook helps report status, but it is bypassable and does not protect terminal/file RPC.”
**availability.** Checks run in parallel with a 5 s deadline each; “malformed output, exceptions and timeouts all look like unavailable”, and “a registered provider is not by itself enough”.
# Agent providers and the provider bridge
> The server-side declaration and the on-machine bridge, the ACP dialects, and bridge record mode.
An agent provider lets a plugin own the agent. It is two halves in one artifact: the declaration on the server and the bridge on the machine. This page also covers the ACP dialects and bridge record mode.
| Half | Import | Main names |
| -------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| declaration (server) | `@get-bb/plugin-sdk` | `bb.providers.register(declaration)` → `{ dispose() }` · `PluginProviderDeclaration` |
| bridge (host) | `@get-bb/plugin-sdk/provider-bridge` | `experimental_defineProviderBridge` · `PROVIDER_BRIDGE_EXPORT_NAME` · `BRIDGE_REQUEST_METHODS` · `BRIDGE_JSON_RPC_ERRORS` · `PROVIDER_BRIDGE_PROTOCOL_VERSION` · `THREAD_DELTA_GRAMMAR_V2`/`V3` · `runBridgeRequest` · `createBridgeIo` · `addTokenUsage` |
| ACP kit | `…/provider-bridge/acp` | `experimental_acpProviderBridge` · `experimental_acpLaunchSpecSchema` · `experimental_probeAcpAgent` |
| conformance | `…/provider-bridge/testing` | `experimental_runBridgeConformance` · `experimental_captureBridgeJsonRpcOutput` · `experimental_createDeltaAssembler` · `experimental_replayRecording` · `experimental_compareParity` |
```ts
// examples/plugins/echo-provider/src/provider-bridge.ts (abridged) — a minimal bridge
const handlers: Record = {
[BRIDGE_REQUEST_METHODS.initialize]: (id, params) => {
io.sendResult(id, {
protocolVersion: PROVIDER_BRIDGE_PROTOCOL_VERSION, // exactly 2
capabilities: {
grammarVersions: [THREAD_DELTA_GRAMMAR_V3, THREAD_DELTA_GRAMMAR_V3],
sessionRestore: true, threadArchive: false, threadRename: false,
threadGoalClear: false, fork: "none",
approvalEnforcedBy: "runtime", steerMode: "queue",
},
});
},
[BRIDGE_REQUEST_METHODS.threadStart]: (id, params) => { /* … io.sendResult(id,
{ providerThreadId, sessionRestorable: true }) … */ },
};
export const experimental_providerBridge = experimental_defineProviderBridge({ handleLine, start });
```
**Transport.** Line-delimited JSON-RPC 2.0 over the bridge process’s stdin/stdout in both directions; requests and responses are told apart by the presence of `method`, “never by the shape of the result”; the two directions have independent id spaces. Non-protocol stdout is ignored; the bridge must guard its own stdout.
**Grammar.** The assembler speaks only v3 (`[3, 3]`): a bridge whose range does not include 3 (including one that predates the field) is rejected at spawn with a clear error. A handshake fact can only narrow what the declaration announced, never widen it.
**Who owns what.** The server mints `threadId`, the provider mints `providerThreadId`, and the assembler in the daemon mints turn and item ids. “The bridge performs no id translation.”
**The minimal correct turn.** `input.accepted { clientRequestId }` → `turn.open` → item deltas → `turn.boundary { status }`. Even a prompt that does no work must emit the open+boundary pair: “zero-delta acceptance is hung-thread class #1431”. After `thread/stop` the bridge holds nothing back: everything it still owes, above all the terminal boundary of the interrupted turn, must go out on the wire before it answers.
**recovery.** Typed, never matched by text: `sessionArchived`, `authRequired`, `restartRecommended`, `staleTurn`, `rateLimited`. One payload, two carriers: a hint about a rejected request travels in `error.data.recovery`, an unsolicited one as a `provider/recovery` notification. “Never send both for one event.”
**Traps.** The SDK belongs in `dependencies` (the documented exception); private `@bb/*` is forbidden; the bridge must not start itself (the daemon’s bootstrap owns argv, `dataDir`/`tempDir`, the stdin framing and the signals); do not mint `providerThreadId` from a process counter; and a bridge that spawns children must use `sanitizeInheritedChildProcessEnv`.
**Who ships one.** `provider-claude-code`, `provider-codex` (“the largest working example”), `provider-pi`, `provider-acp`, and `examples/echo-provider` (“the smallest”).
Reference: `docs/provider-bridge-protocol.md` · `docs/provider-plugin-api.md`.
## ACP agents and dialects
[Section titled “ACP agents and dialects”](#acp-agents-and-dialects)
Five registered dialects, five shipped agents.
| provider id | displayName | launch spec |
| ------------------ | ------------ | ------------------ |
| `acp-cursor` | Cursor | `cursor-agent acp` |
| `acp-opencode` | opencode | `opencode acp` |
| `acp-omp` | omp | `omp acp` |
| `acp-grok` | Grok Build | `grok agent stdio` |
| `acp-hermes-agent` | Hermes Agent | `hermes acp` |
The dialects in the code are `acp` (a generic no-op), `grok`, `cursor`, `omp` and `opencode`: five, where the documentation names three. The code is taken as the truth, but the discrepancy is **not verified** against the changelog.
Selection is two-stage: an explicit `providerOptions.acpDialect` wins; otherwise `basename(launch.command)` is matched against `DIALECT_IDS_BY_COMMAND`, and anything unknown falls back to the generic dialect. User agents come from the `customAgents` setting, get an `acp-` prefix and the glyph `"Toolbox"`; all ACP registrations share `family: "acp"`. The dialect registry is deliberately not public: “no plugin has needed it, and its shape — process-global, unversioned hooks — is still open”.
## Bridge record mode
[Section titled “Bridge record mode”](#bridge-record-mode)
Provider diagnostics without guesswork.
```plaintext
BB_PROVIDER_BRIDGE_RECORD_DIR=
// every bridge process mirrors BOTH boundaries into
// ///.ndjson — four directions,
// one line of { ts, run, seq, dir, line } each, with seq a single counter across all tracks.
// A bridge that spawns a CLI calls experimental_recordProviderChildIo(child, { threadId })
// right after spawn(); one whose pipe belongs to the SDK checks
// experimental_isProviderBridgeRecording() and takes the spawn over itself.
// "A recording is never overwritten": a deliberate bridge change writes
// bridge→runtime.current.ndjson alongside it.
```
# Thread plugin metadata
> A JSON namespace that travels with the thread and does not reach the model automatically.
Thread plugin metadata gives a plugin a JSON namespace that travels with the thread and does not reach the model automatically. This page shows how to read and write it, then covers trust, timing and limits.
```ts
const current = await bb.sdk.threads.getPluginMetadata({ threadId });
const updated = await bb.sdk.threads.updatePluginMetadata({
threadId,
set: { status: "reviewing", result: null }, // null is stored AS DATA
remove: ["requestedBy"], // absent keys are ignored
});
// a safe read inside configure — the value is quoted so it reads as data,
// not as instructions
bb.agents.configure((context) => {
const { issueKey } = context.pluginMetadata; // a deep-frozen snapshot; writing throws
const hasIssue = typeof issueKey === "string" && /^[A-Z]+-\d+$/u.test(issueKey);
return { tools: hasIssue ? ["review-result"] : [], skills: [],
...(hasIssue ? { instructions: `Linked issue key (data): ${JSON.stringify(issueKey)}` } : {}) };
});
```
**Not a trust boundary.** “Any API client, another plugin or the thread’s own agent can write any namespace”. Treat the values as untrusted input, and let them enable only those tools that would be safe even if the agent had set the value itself. Store no secrets there, and do not use it for authorization.
**Timing.** Seeds from spawn and from an explicit fork are visible on the first configuration pass; later updates appear on subsequent passes and neither restart nor alter a turn in flight.
**Limits.** 256 KiB of UTF-8 JSON per namespace; an oversized seed or `set` is rejected before the request (HTTP 400 for raw clients), and a patch whose merged namespace would exceed the limit fails with HTTP 413 and leaves the namespace unchanged. Forks never inherit the source’s metadata.
**First-party consumers:** none.
# How a plugin works
> The three entries a plugin can have, how they talk to each other, where state lives, and what happens when bb loads and reloads a plugin.
A plugin is an npm package with up to three entries, one for each of bb’s processes. The server entry is required and runs inside the server; the optional app entry runs in the app window, and the optional host entry runs on the machine where the agent works. The entries share no memory. They talk over RPC, a realtime push channel and host RPC, and all durable state lives with the server entry. bb loads every entry at full trust. If the three processes are new to you, read [How bb works](/bb-plugin-atlas/concepts/) first.
## Three entries
[Section titled “Three entries”](#three-entries)
The manifest is the plugin’s own `package.json`. Its `bb` block names the entries:
```jsonc
"bb": {
"name": "Hello",
"server": "./server.ts", // required
"app": "./app.tsx", // optional: UI in the app window
"host": "./host.ts" // optional: code on the agent's machine
}
```
| Entry | Runs in | Use it for | Its shape |
| ------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------ |
| server entry, `bb.server` | the bb server process | storage, RPC and HTTP routes, CLI commands, agent tools, providers, schedules | a default-exported factory `(bb: BbPluginApi) => …` |
| app entry, `bb.app` | the app window, in the same JS realm as bb’s own UI | panels, controls and renderers in the window, composer additions, palette commands | `definePluginApp((app) => …)` |
| host entry, `bb.host` | a worker process the host daemon starts on its machine | files, processes and the agent’s bridge on the machine where the agent runs | `experimental_defineHostEntry({ contract, handlers })` |
The server entry is required even when a plugin only adds UI: `plugins/scheduled-send/server.ts` is a deliberate stub. The manifest fields in full are in [Package anatomy](/bb-plugin-atlas/package/).
Neither the server entry nor the app entry runs a main loop. The server entry is a function bb calls once per load, handing it a fresh `bb` object. The function registers what the plugin provides (an RPC method, a CLI command, a setting, an agent tool) and returns. The app entry does the same through `app`: it registers a panel, a slot component or a command, and bb mounts the component where that slot sits in the window. From then on bb calls the plugin’s code when a person, the agent or another process needs it. The registration calls are listed in [Backend namespaces](/bb-plugin-atlas/backend/) and [Frontend slots](/bb-plugin-atlas/frontend/).
## How the entries talk
[Section titled “How the entries talk”](#how-the-entries-talk)
**direct call (steps 1–8)**push back**plugin code
#### app.tsx
useRpc\()
rpc.call("listIssues", { … })
import type — the backend is erased from the bundle
1↓ `rpc.call(…)` → POST /plugins/\/rpc/\
#### POST /plugins/\/rpc/\
input schema → handler → output schema
2↓ `validation` → server.ts — the factory, in the server process
#### server.ts — the factory, in the server process
bb.rpc.register(…)
bb.http.route(…)
bb.cli.register({ name, run })
bb.agents.registerTool(…)
bb.realtime.publish(…)
the result is strict JSON
3↓ `bb.realtime.publish` → WebSocket /ws — one shared socket (signal back)
8↓ `host RPC` → host.ts, on the agent’s machine
↓ `read and write` → state and the outside world
#### useRealtime("issues:changed", fn)
the same component refetches its data
#### WebSocket /ws — one shared socket
{ type, pluginId, channel, payload }
broadcast to all; the client filters
4↓ `useRealtime → refetch` → useRealtime("issues:changed", fn) (signal back)
#### bb CLI
bb \ … · argv without the command name
5↓ `POST /cli` → POST /plugins/\/cli
#### POST /plugins/\/cli
run() executes on the server
↓ server.ts — the factory, in the server process
#### agent tool
the tool set is frozen at session start
6↓ `execute(…)` → called inside the server process
#### called inside the server process
errors come back as text
↓ server.ts — the factory, in the server process
#### external service · webhook
POST with the signature in the request body
7↓ `signature in the body` → ALL /plugins/\/http/\
#### ALL /plugins/\/http/\
your own route, exact path match
↓ server.ts — the factory, in the server process
#### state and the outside world
bb.storage.kv · bb.storage.database()
fetch · fs · child\_process
#### host.ts, on the agent’s machine
called through bb.hosts.experimental\_client(…)
signals back with experimental\_emitSignal(…)
1–2 RPC · 3–4 realtime · 5 CLI · 6 agent tool · 7 webhook over an HTTP route · 8 host RPC. Five callers, one server entry, and no shared memory between any of them.
The server entry is the hub. Every other caller reaches the plugin through it, and each caller has one channel:
| Channel | From → to | Use it for | Reference |
| ---------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| RPC | app entry → server entry | reading and changing data from the UI; the contract is defined once in the server entry, and the app entry imports only its type | [`bb.rpc`](/bb-plugin-atlas/backend/rpc/) |
| realtime | server entry → every open window | telling pages that data changed so they fetch it again; nothing is stored or replayed | [`bb.realtime`](/bb-plugin-atlas/backend/realtime/) |
| CLI | the `bb` CLI → server entry | a `bb ` for people and for the agent; the command runs on the server, not in the CLI | [`bb.cli`](/bb-plugin-atlas/backend/cli/) |
| agent tool | the agent → server entry | a native tool in the agent’s session, called inside the server process | [`bb.agents`](/bb-plugin-atlas/backend/agents/) |
| HTTP | an outside service → server entry | webhooks and other callers that are not bb | [`bb.http`](/bb-plugin-atlas/backend/http/) |
| host RPC | server entry ⇄ host entry | work on the agent’s machine, with signals coming back | [`bb.hosts`](/bb-plugin-atlas/backend/hosts/) |
Two channels are absent by design. The app entry cannot call the host entry: it calls the server entry over RPC, and the server entry calls the host entry. And no entry can reach another plugin directly.
A skill completes the picture for the agent. A plugin ships `skills//SKILL.md`, bb injects it into the agent’s threads, and the skill tells the agent which `bb` command to run. The agent then goes through the CLI channel like a person would, instead of touching the plugin’s storage. [Your first plugin](/bb-plugin-atlas/start/) walks through a scaffold that uses RPC, realtime, the CLI and a skill against one store.
## State lives with the server entry
[Section titled “State lives with the server entry”](#state-lives-with-the-server-entry)
The server entry owns every piece of durable state: values in `bb.storage.kv`, a SQLite database of its own from `bb.storage.database()`, and settings declared with `bb.settings.define`. The app entry keeps only what it renders. It fetches over RPC and fetches again when a realtime signal arrives, so every open window shows the same data. A host entry keeps its own files on its machine, in the directory bb gives it.
This is what keeps a plugin small as it grows. The page, the CLI command and the agent tool are three clients of the same functions in the server entry, so adding a surface to a finished plugin usually means one more call into code that already exists.
## Load, reload and dispose
[Section titled “Load, reload and dispose”](#load-reload-and-dispose)
bb runs the server entry’s factory every time it loads the plugin: when the server starts, after an install, on `bb plugin reload`, and after every save while `bb plugin dev` is watching. Four rules follow from that:
1. The factory is time-boxed to 30 seconds. Work that takes longer, or runs forever, belongs in a background service registered with `bb.background.service`.
2. The `bb` object is valid for one load. After a reload, a reference kept from the previous load throws `PluginContextStaleError`, so `bb` never goes into a module-level variable.
3. A reload loads the new instance first and disposes the old one afterwards. If the new factory throws, the old instance keeps running, and its status stays `running` with the detail “reload failed: …”.
4. Cleanup goes into `bb.onDispose`. The hooks run in reverse order of registration when the plugin reloads, is disabled or the server shuts down.
In the app window, bb runs the app entry’s setup again whenever the bundle changes. A slot component that throws is caught by its own error boundary: bb shows a fallback, the rest of the window keeps working, and the slot stays disabled until the plugin reloads. The exact load and dispose steps, the eight statuses and the hot-reload loop are in [Runtime and lifecycle](/bb-plugin-atlas/runtime/).
## Full trust
[Section titled “Full trust”](#full-trust)
bb does not sandbox plugins. The server entry runs inside the server process with unrestricted `fs`, `net`, `child_process` and `fetch`; the app entry is same-origin page code in bb’s own window; the host entry runs as the same OS user as the agent. The security boundary is the decision to install a plugin. [Trust model](/bb-plugin-atlas/trust/) lists what plugin code can reach and the design rules that follow from it.
# Choosing a surface
> Start from what your plugin should do, then find the surface that fits and the first-party plugin that already uses it.
A surface is one place a plugin appears or acts: a page in the sidebar, a button beside the prompt box, a tool in the agent’s session. bb names 46 surfaces in seven groups. To choose, start from what the person or the agent should experience, open a first-party plugin that already does it, and then read the surface’s reference page for the exact API. The tables below cover the common goals; all 46 surfaces are on [Surfaces](/bb-plugin-atlas/surfaces/), and the [UI zone map](/bb-plugin-atlas/map/) shows the window surfaces in place.
## Four questions
[Section titled “Four questions”](#four-questions)
A plugin can answer yes to several of these; the scaffold in [Your first plugin](/bb-plugin-atlas/start/) answers the first two.
1. Does a person see or click it in the bb window? Then you need an app entry. The surfaces are in the first six groups: the app window, the command palette, the composer, the home page, the plugin’s settings page and the plugin page.
2. Does the agent use it while it works? Then the server entry gives the agent a CLI command and a skill that teaches it, a native tool, or instructions.
3. Does it run with nobody watching? Then the server entry runs background services and schedules, reacts to thread events, and serves HTTP routes for outside services.
4. Does it change which agent runs, or where? Agent, environment and machine providers are [extension points](/bb-plugin-atlas/advanced/); agent providers and the first-party environment providers also ship a host entry.
## In the bb window
[Section titled “In the bb window”](#in-the-bb-window)
| To add… | Surface | Plugin to read |
| ------------------------------------------------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| a page of your own, opened from the sidebar | [Full-page panels](/bb-plugin-atlas/surfaces/nav-panel/) | `tasks`, `github`; the smallest is `plugin-api-tester` |
| a tab in the panel beside a thread | [Thread side-panel tabs](/bb-plugin-atlas/surfaces/thread-panel/) | `side-chat`, `github` |
| a component inside the agent’s reply | [Rich message embeds](/bb-plugin-atlas/surfaces/message-directives/) | `inline-vis`, the smallest |
| a form that pauses the agent to ask the person | [In-thread forms](/bb-plugin-atlas/surfaces/pending-interaction/) | `ask-user-question`, `secrets` |
| an action on a single message | [Message actions](/bb-plugin-atlas/surfaces/message-actions/) | `side-chat` |
| a viewer or an editor for a file type | [File viewers & editors](/bb-plugin-atlas/surfaces/file-opener/) | `pdf-preview`, the smallest; `monaco-editor` |
| an item at the bottom of the sidebar | [Sidebar footer items](/bb-plugin-atlas/surfaces/sidebar-footer/) | `connect` |
| rows in the + menu beside the prompt box | [The + menu](/bb-plugin-atlas/surfaces/composer-plus-menu/) | `drafts`, `scheduled-send` |
| a banner above the prompt box | [Banners](/bb-plugin-atlas/surfaces/composer-banners/) | `provider-retry`, `workflows` |
| results in the mention menu | [Mentions](/bb-plugin-atlas/surfaces/mention-provider/) | `github`, `tasks` |
| a command in the command palette | [Command palette actions](/bb-plugin-atlas/surfaces/command-palette-actions/) | `monaco-editor` |
| a section on the home screen | [Home-screen sections](/bb-plugin-atlas/surfaces/homepage-section/) | no first-party plugin uses it |
| a replacement for the thread list | [The thread list](/bb-plugin-atlas/surfaces/thread-list/) | `replacement-lab-alpha` with `replacement-lab-beta`; install both |
| settings the person fills in | [Settings fields](/bb-plugin-atlas/surfaces/declarative-settings/) | `custom-instructions`, `github` |
| your own component on the settings page | [Custom settings section](/bb-plugin-atlas/surfaces/settings-section/) | `keep-awake`, `memory` |
| a notice that the plugin needs configuring before it can run | [Configuration status](/bb-plugin-atlas/surfaces/plugin-status/) | `github`, `workflows` |
Your UI shares the window with bb’s own in one of four ways. On an additive surface your component sits next to bb’s components and those of other plugins. A replacement surface swaps out a bb component and falls back to it if yours crashes. The thread list is exclusive: one plugin replaces the whole list. And for host chrome such as sidebar footer items, bb draws the control from data your plugin supplies. The four kinds are defined on [Frontend slots](/bb-plugin-atlas/frontend/).
## For the agent
[Section titled “For the agent”](#for-the-agent)
| To give the agent… | Surface | Plugin to read |
| ------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------- |
| a command it runs, taught by a skill | [bb CLI commands](/bb-plugin-atlas/surfaces/cli/) | the scaffold; `memory`, `tasks` |
| a native tool in its session | [Agent tools & skills](/bb-plugin-atlas/surfaces/agent-tools/) | `ask-user-question`, `memory` |
| instructions only, with no UI | [Agent tools & skills](/bb-plugin-atlas/surfaces/agent-tools/) | `custom-instructions`, `bb-guide` |
| a gate that decides whether a message may be sent | [Dispatch hook](/bb-plugin-atlas/surfaces/dispatch-hook/) | `concurrency-limit` |
| data that travels with the thread | [Thread plugin metadata](/bb-plugin-atlas/surfaces/thread-plugin-metadata/) | `workflows` |
## With nobody watching
[Section titled “With nobody watching”](#with-nobody-watching)
| To run… | Surface | Plugin to read |
| -------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------- |
| a long-running service or a cron schedule | [Background work](/bb-plugin-atlas/surfaces/background/) | `automations`, `keep-awake` |
| code when a thread starts, finishes or fails | [Thread lifecycle events](/bb-plugin-atlas/surfaces/thread-events/) | `push-notifications`, `provider-retry` |
| a webhook endpoint with signature checking | [HTTP, WebSocket, RPC & realtime](/bb-plugin-atlas/surfaces/wire/) | `slack-bot`, an example with no dependencies |
| your own SQLite database with migrations | [Storage](/bb-plugin-atlas/surfaces/storage/) | `memory`, `github`, `tasks` |
| code that creates threads and projects | [The bb SDK](/bb-plugin-atlas/surfaces/bb-sdk/) | `automations`, `tasks` |
## Where and how the agent runs
[Section titled “Where and how the agent runs”](#where-and-how-the-agent-runs)
| To provide… | Surface | Plugin to read |
| ------------------------------------ | ------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| a new agent in the model picker | [Agent providers](/bb-plugin-atlas/surfaces/provider-picker/) | `provider-acp`, `provider-pi`; the smallest is the `echo-provider` example |
| the place a thread’s workspace lives | [Environment providers](/bb-plugin-atlas/surfaces/environment-providers/) | `environment-git-worktree`, `environment-project-checkout` |
| a machine to run threads on | [Machine providers](/bb-plugin-atlas/surfaces/machine-providers/) | `environment-modal-sandbox` |
| your own code on the agent’s machine | [Host workers](/bb-plugin-atlas/surfaces/host-workers/) | `keep-awake`, `environment-personal-workspace` |
The fullest first-party reference is `tasks`. The [catalog](/bb-plugin-atlas/plugins/) lists every first-party plugin with its entries and size.
# CLI, build, distribution
> The bb plugin command reference, install kinds and tracking, engine gating, publishing, and where the bb binary lives.
This page lists the plugin commands of the bb CLI and covers install kinds and tracking, engine gating, rollback, marketplaces, publishing, and where the bb binary lives in a desktop install.
## `bb plugin *` reference
[Section titled “bb plugin \* reference”](#bb-plugin--reference)
| Command | Flags | What it does |
| ------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `plugin search ` | `--json` | search everything the store lists (bundled + `bb-community` + added marketplaces); the Marketplace column names the source |
| `plugin list` | `--json` | installed plugins and their status, plus services, schedules, handler timings and contributions to `bb` commands |
| `plugin source ` | `--json` | the resolved source and its history: subdir, semver range + tag prefix + resolved tag, engine ranges, install time, integrity, activation history |
| `plugin install ` | `--subdirectory` · `--plugin` · `--tag-prefix` · `--yes` · `--json` | a catalog entry by name or `@`, a Git URL, a local path, `builtin:`, `git:[@]`, `npm:@` |
| `plugin outdated` | `--json` | check for compatible updates; also reports newer releases that are blocked as incompatible |
| `plugin update [id]` | `--all` · `--yes` | apply compatible candidates only; the same full-trust confirmation as install; non-TTY without `--yes` refuses |
| `plugin new ` | none | scaffold into `./bb-plugin-`; accepts `@scope/bb-plugin-` |
| `plugin types [path]` | `--check` | sync the SDK surface with the running bb: re-pin the SDK devDependency and the type-only devDeps of shimmed packages; `--check` writes nothing |
| `plugin migrate [path]` | `--yes` | move a plugin that vendors `types/` onto the npm package: pin the devDependency, drop the tsconfig path map, delete the vendored declarations, rewrite `@bb/plugin-sdk` imports; prints the plan and asks |
| `plugin build [path]` | none | build into `dist/`: server always, app when `bb.app`, a self-contained host when `bb.host`; no server required |
| `plugin dev [path]` | none | watch: rebuild the frontend (without minification), the host and the provider-bridge bundles, then reload on every change |
| `plugin reload [id]` | `--json` | reload one plugin or all of them; exits 1 if the reloaded plugin did not come up |
| `plugin enable` / `disable ` | `--json` | enable / disable (the code is unloaded) |
| `plugin config [action] [key] [value]` | `--json` | show settings, or `config set ` / `unset `; booleans and numbers are coerced to the declared types |
| `plugin token ` | `--rotate` · `--json` | print the plugin’s HTTP token (for routes with `auth: "token"`) |
| `plugin run [args...]` | `passThroughOptions` | the explicit form of `bb `, which removes any name collision |
| `plugin logs ` | `-n/--lines` (100) · `-f/--follow` | the plugin’s log (`bb.log` output); follow polls once a second |
| `plugin remove ` (alias `uninstall`) | `--json` · hidden `--yes` | remove the plugin and its settings, secrets and schedules; managed git/npm files are deleted, local path sources stay on disk |
| `plugin rpc list [plugin-id]` | `--method` · `--json` | the discoverable RPC methods of running plugins |
| `plugin rpc inspect [method]` | `--json` | registration and method descriptions plus the input/output JSON Schema |
| `plugin rpc call ` | `--input-file` · `--json` | call a method with server-side schema validation; with no file, JSON `null` is sent (a file keeps secrets out of argv) |
Nearby: `bb marketplace add|list|refresh|remove`; `bb theme list|set|show` (which shows plugin palettes as `plugin::`); `bb settings keyboard` (plugin commands are rebound under `plugin:/`); and `bb diagnostics cli-errors` (tallies of failed `bb` invocations; it records the command path and the error code, never argument values; `BB_CLI_ERROR_LOG=0` turns it off).
## Install kinds and tracking
[Section titled “Install kinds and tracking”](#install-kinds-and-tracking)
```bash
bb plugin install ./bb-plugin-notes # path
bb plugin install path:. --plugin notes # path + a collection entry
bb plugin install npm:bb-plugin-notes@^1.0.0 # npm: range / dist-tag / exact version
bb plugin install https://github.com/acme/bb-plugin-notes # bare URL → the default branch
bb plugin install git:https://github.com/acme/x.git@main # an explicit branch (tracking)
bb plugin install git:https://github.com/acme/x.git@^1.2.0 # semver over vX.Y.Z tags
bb plugin install builtin: # a local copy from the application bundle
bb plugin install @ # a marketplace entry
```
### Tracking versus pinning
[Section titled “Tracking versus pinning”](#tracking-versus-pinning)
* npm: an omitted spec tracks compatible stable releases; ranges and dist-tags track, exact versions pin.
* git: an omitted ref tracks the default branch; explicit branches track, tags and commits pin.
* A git semver range resolves over `vX.Y.Z` tags, taking the highest match, with prereleases excluded unless the range names them. `--tag-prefix` gives per-plugin tags in a monorepo.
* bb records the chosen tag together with its commit and refuses to resolve the tag again if it has moved (the `go.sum` lesson). A fix is published as a new version, not as a re-tag.
* A bare spec that reads as a range resolves over tags only if no branch and no tag carries that literal name; if both exist, the install fails, and you disambiguate with `@semver:` or `@ref:`.
### Build behavior per kind
[Section titled “Build behavior per kind”](#build-behavior-per-kind)
* **path**: loads the `bb.server` TypeScript directly, with no build; `bb.app` is built at install time from the already-installed dependencies. Installing a local path for an id already installed from another local path moves the plugin and keeps its settings, secrets and schedules.
* **git**: first `npm install --omit=dev --omit=optional --ignore-scripts`, keeping `node_modules` (bundling cannot inline data files read at runtime), then the declared app/server/host builds and metadata validation. `git` is required; npm and Node on PATH are not, since bb ships npm.
* **npm**: must ship a ready and valid `dist/`, with `app.js` + `app.meta.json` when `bb.app` is declared, and the host bundle with its metadata when `bb.host` is.
* **builtin**: copied from the application bundle, with no network.
* Reinstalling an already-installed managed plugin is rejected; use `bb plugin update`. Changing the pinned `git:`/`npm:` source requires `remove` (losing settings) and a fresh install.
## Engine gating, rollback, marketplaces
[Section titled “Engine gating, rollback, marketplaces”](#engine-gating-rollback-marketplaces)
* Updates pick only compatible candidates that satisfy `engines.bb` and `engines.bbPluginSdk`; newer incompatible releases are reported as blocked, not applied. Dev builds note that `engines.bb` was not checked.
* Rollback: a failed activation restores the previous state snapshot and records the failure for the user.
- A local path plugin is never removed to change it: edit in place and `bb plugin reload `, or `bb plugin install path:`; both keep the configuration.
- Removing a marketplace leaves each of its plugins working as a direct install with the full source intent and exact resolution, so `outdated`/`update` keep working.
- A marketplace is one `marketplace.json`; it lists plugins with store branding and an npm/git source, and it never hosts code. The schema is strict: an unknown field rejects the whole document, and the last valid catalog keeps serving. `bb-official` and `bb-community` are reserved and can be neither added nor removed. A listing declares no compatibility: `engines` are read from the plugin’s own `package.json`.
- Before installing from a marketplace other than `bb-community`, bb resolves and shows the real source (including the exact release tag and the commit a range lands on), plus the marketplace itself and the entry’s author; the install fails if the listing or the resolved commit changed after confirmation.
- `bb-official` describes every plugin in the application bundle with a generated v2 document pointing at a local path source, and never reaches the network. `bb-community` is re-read at startup and every two hours from `https://getbb.app/marketplace/v2/marketplace.json` (with one v1 fallback on a 404; overridden by `BB_MARKETPLACE_URL`, which is read only at startup). Install counts come from a `stats.json` next to the curated manifest and “undercount by construction”: they are installs bb heard about.
## Publishing and releasing versions
[Section titled “Publishing and releasing versions”](#publishing-and-releasing-versions)
Installs from `path` and from `git` build the plugin in place. An npm package must ship an already built and valid `dist/`: `dist/app.js` + `dist/app.meta.json` when `bb.app` is declared, and the host bundle with its metadata when `bb.host` is. A managed install checks the metadata and rejects an artifact whose `pluginId`/`pluginVersion` disagree with the manifest or whose `sdkMajor` is not the current one.
```bash
# before publishing
bb plugin types # re-pin the SDK and type-only devDeps to the current bb
bb plugin types --check # the same in CI: writes nothing, fails on a mismatch
bb plugin build # dist/server.js (+ app, + host) and their *.meta.json
npm publish # the package MUST contain dist/ — the scaffold's .gitignore hides it
# from git only, but it has to reach the npm package
```
The scaffold writes a `.gitignore` of exactly two lines (`dist/` and `node_modules/`) and creates no `files` field in `package.json`. What exactly to list in `files` (or in `.npmignore`) so that `dist/` reaches the tarball is described nowhere in the repository (**not verified**); check your own package with `npm pack --dry-run` before publishing.
### Versioning versus `engines`
[Section titled “Versioning versus engines”](#versioning-versus-engines)
* `engines.bb` is a range against the application version; `engines.bbPluginSdk` is read as a floor, not a ceiling. The scaffold writes `>=major.minor` of the current bb and the exact SDK version.
* Updates pick only compatible candidates. A newer release that fails `engines` is shown by `bb plugin outdated` as blocked, not applied. A managed install additionally refuses a plugin whose SDK is newer than the host or of a different major.
* Publish a fix as a new version: bb records the chosen tag together with its commit and refuses to resolve a tag that has moved.
* For a git source the semver range resolves over `vX.Y.Z` tags; in a monorepo several plugins are separated by `--tag-prefix`.
### Store copy and submitting to `bb-community`
[Section titled “Store copy and submitting to bb-community”](#store-copy-and-submitting-to-bb-community)
* Two texts, both required by the store: `bb.description`, one sentence up to \~140 characters on the Browse card and in the first paragraph of the page; and `PLUGIN_OVERVIEW.md` next to `package.json`, the same claim at length, in sections below it.
* CI rules for the overview: UTF-8, a hard ceiling of 4000 characters (aim for 700 to 1800); headings, paragraphs, emphasis, strikethrough, inline code, code blocks, quotes, lists, separators and links only; no raw HTML, images, tables, footnotes or checklists; every link absolute https; `##`-level sections; do not start with `#` and do not repeat the short description as the first sentence.
* Submission is its own skill, `plugins/bb-guide/skills/submit-a-plugin/`: it copies `PLUGIN_OVERVIEW.md` into the marketplace repository as `overview/.md` and references it with the `"overview"` field. Landing in `bb-community` is a bb release decision, not part of the author’s process.
* Your own marketplace is one `marketplace.json`, added with `bb marketplace add` from an https URL, `git:` or `path:`; it hosts no code, and installs go through the same pipeline.
## The `bb` binary in a desktop install
[Section titled “The bb binary in a desktop install”](#the-bb-binary-in-a-desktop-install)
The portable answer is the `BB_CLI` environment variable, holding an absolute path to the daemon-managed executable: “Prefer bare `bb` on PATH. When `BB_CLI` is set, official `bb` entrypoints re-exec to that absolute binary; you can also invoke `"$BB_CLI"` directly”. The launcher sets `BB_CLI: join(args.context.daemonBundleDir, "bb")`, and for a packaged install `daemonBundleDir = /host-daemon/dist`.
A path of the form `bb.app/Contents/Resources/app.asar.unpacked/node_modules/bb-app/host-daemon/dist/bb` does not appear literally anywhere in the repository. It is assembled from three separate facts and is therefore marked **not verified**; use `$BB_CLI`.
# How bb works
> The three processes a plugin lives among, the objects bb manages in them, and why bb's own features are plugins too.
bb is three kinds of process: a server that holds all state, the app window you work in, and a host daemon on every machine where an agent does its work. A plugin adds code to one or more of those processes. This page describes the processes and the objects bb keeps in them; [How a plugin works](/bb-plugin-atlas/architecture/) then places plugin code among them.
**request**push back**plugin code
Client: the bb app window (web, desktop, mobile) and the bb CLI
#### the bb app window and the bb CLI
threads, the composer, the sidebar, settings
bb \, typed by a person or run by the agent
↓ `HTTP API` → the bb server
#### bb.app · app entry
your UI, mounted in the same window
↓ `RPC` → bb.server · server entry
Server: one per installation, SQLite is the source of truth
#### the bb server
projects, threads, settings, plugins
the HTTP API and WebSocket /ws
127.0.0.1 by default
↓ `WebSocket /ws` → the bb app window and the bb CLI (signal back)
↓ `thread commands` → the host daemon
#### bb.server · server entry
required
your logic, inside the server process
↓ `host RPC` → bb.host · host entry
Host daemon: one per machine that runs agents
#### the host daemon
starts the agent provider process
the thread’s workspace directory is on this machine
enrolls with the server as a host
↓ `events back` → the bb server (signal back)
#### bb.host · host entry
optional
your code on that machine
The server holds all state; the app window, the CLI and every host daemon are its clients. A plugin always has a server entry, and adds an app entry or a host entry when it needs one.
## Three processes
[Section titled “Three processes”](#three-processes)
The server is a Node process with SQLite as the source of truth. It stores every project, thread, setting and installed plugin, serves the HTTP API and one WebSocket, and binds to `127.0.0.1` by default. Every other part of bb reaches state through the server.
The app window is a client of the server: the web client, the desktop application or the mobile client. It asks the server for data over HTTP and receives changes as pushes over the WebSocket. The `bb` CLI is a client of the same server, and so is the agent, which reaches bb through the CLI and through the tools and skills bb hands it. When an agent runs `bb hello add`, the command travels the same path as one typed by a person.
The host daemon runs on each machine that executes work, including the server’s own machine. It starts the agent’s process, which bb calls the agent provider, holds the workspace directories threads work in, and keeps a WebSocket session with the server. A machine created by a machine provider runs its own installed daemon, which enrolls with the server.
## What bb manages
[Section titled “What bb manages”](#what-bb-manages)
A **project** groups work around a repository and carries its `gitRemoteUrl`. Working outside any project still uses a project: the personal one, `kind: "personal"`.
A **thread** is the unit of work. It holds one conversation with an agent provider and produces an append-only stream of events that the app window renders as the timeline. A **turn** is one exchange inside a thread, from the message the user sends to the end of the agent’s answer. A standard thread does the work itself; a manager thread coordinates other threads.
An **environment** is where a thread runs: a workspace directory bound to one host. An unmanaged environment points at a directory that already exists; a managed one, such as a git worktree, is created for the thread and removed once no unarchived thread uses it.
A **host** is the long-lived identity of one machine’s daemon. The server’s own machine is `primaryHostId`. A **machine** is what a machine provider provisions; once its daemon enrolls, it appears as a host.
These objects are the vocabulary of the plugin API. An environment provider prepares a workspace on a host, a thread lifecycle event reports a turn that finished or failed, and a panel in the app window learns the current project and thread from `useBbContext()`.
## bb’s own features are plugins
[Section titled “bb’s own features are plugins”](#bbs-own-features-are-plugins)
Almost everything visible in the app window, and almost everything the agent can do, is a plugin on the same public SDK a third-party plugin uses. The agent providers for Claude Code, Codex, Pi and the ACP agents are plugins; so are git worktree environments, task tracking, the GitHub integration and the multiple-choice questions an agent asks in a thread. bb ships 36 first-party plugins and 9 examples, listed in the [catalog](/bb-plugin-atlas/plugins/).
The repository’s design documents state the rule behind this: core owns the minimum, and “every special case is a public primitive or is deleted”. Core “never branches on a provider id”.
Two consequences follow for a plugin author. Any feature you see in bb is evidence that a plugin can do something similar, through an API you can read. And the fastest way to learn an API is to open the first-party plugin that uses it; [Choosing a surface](/bb-plugin-atlas/choose/) maps ideas to those plugins.
# Glossary and sources
> One definition per term, and where to go for the truth.
This page gives one definition per term for plugin anatomy, frontend surfaces and the domain, lists names that bb does not have, and points to the sources in the bb repository.
## Plugin anatomy
[Section titled “Plugin anatomy”](#plugin-anatomy)
| Term | Meaning |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| manifest | the plugin’s `package.json` under the `bb` key. Not a separate file |
| plugin id | derived from the package name (last segment minus `bb-plugin-`, lowercased, non-alphanumerics → `-`). The namespace for routes, storage, settings and the CLI |
| server entry | `bb.server`; the required server entry, a default-exported `(bb: BbPluginApi) => void` |
| host entry | `bb.host`; the optional, single, full-trust Node 22 ESM entry on the host daemon |
| host worker | the forked child process on the daemon that runs one plugin’s host entry for one generation |
| generation | three different meanings: the module-load epoch on the server (`?bbPluginLoad=.`), the `randomUUID()` of a host artifact, and the client’s mount counter |
| artifact | the built output (`server.js` / `app.js` / `host.js`) with its own `*.meta.json` |
| bundled / official plugin | ships inside the application and installs from a local copy, with no network. `bb-official` is a reserved marketplace name |
| marketplace | one `marketplace.json` listing plugins with store branding and an npm/git source. It never hosts code |
| collection manifest | `.bb/plugins.json` at the root of a repository, indexing several plugin directories. An index only: it overrides neither identity nor entries |
## Names that do not exist
[Section titled “Names that do not exist”](#names-that-do-not-exist)
Three names that readers expect and bb does not have:
* There is no `bb-plugin.json` file. The manifest is the plugin’s `package.json`, under the `bb` key.
- `packages/plugin-registry` is not a registry of plugins but a shadcn component registry. The registry of installed plugins is the `plugins` table in SQLite.
* There is no `definePlugin` helper. The server entry is a default-exported function.
[Backend namespaces](/bb-plugin-atlas/backend/) lists the namespace names people guess and bb does not have.
## Frontend surfaces
[Section titled “Frontend surfaces”](#frontend-surfaces)
| Term | Meaning |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| surface | the product grouping used by the documentation and the API map: `PluginSurface { id, title, summary, bullets, apiSymbols, … }` inside seven `SurfaceGroup` objects (`app-shell`, `command-palette`, `composer`, `home`, `settings`, `extensions`, `headless`) |
| slot | a registration point in code, `app.slots.*`. There are 22 of them; the full list is in [Frontend API](/bb-plugin-atlas/frontend/) |
| additive / replacement / exclusive | additive slots sit side by side; replacement slots fall back to bb’s renderer on a crash; `experimental_threadList` is the only exclusive one (one list fills the scroll area) |
| content script | trusted same-origin JS/TS, mounted once per active frontend generation per window or tab, with no React slot. Explicitly not a sandbox |
| nav panel | the slot that owns the whole `/plugins///*` route and gets its own sidebar row; the component receives `{ subPath }` |
| fixed tab | an ordered, non-closable page inside the nav panel’s host tabstrip: `{ id, panelId, title, icon, component, layout?, experimental_target? }`, where `panelId` must equal the panel’s `id` |
| thread panel action | an entry in the Actions list of the thread’s right-hand panel; `run({ threadId, openPanel })` decides what to open |
| message directive | a leaf embed inside the assistant’s Markdown; the `id` is the directive name, so `inline-vis` matches `::inline-vis{file="demo.html"}`. The attributes are untrusted |
| timeline entry / row | the persistent rows the transcript is made of. A renderer receives a `PluginTimelineRendererRow`, whose `kind` is `"tool"` or `"/"` and whose status is `"pending" \| "completed" \| "error" \| "interrupted"`. `ThreadChatMessageReference` is explicitly NOT an internal timeline row |
| interaction | a form in the thread that the backend requests with `bb.ui.requestInput` and the `pendingInteraction` slot draws. bb stores only the plugin’s own `PluginInteractionDescription`, never the payload and never the submitted value |
| composer | the chat input. “Composer customization” is `{ id, scopes?, actions?, plusMenu?, banners?, richText? }`; `scope` is `"thread" \| "queued-message" \| "side-chat" \| "new-thread"` |
| crash chip | the “plugin `` crashed” pill an additive slot renders after its boundary fires |
## Backend and domain
[Section titled “Backend and domain”](#backend-and-domain)
| Term | Meaning |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| thread | the unit of work; holds one conversation with a provider, has lifecycle state and produces an append-only stream of events. A standard thread does the work itself, a manager thread coordinates others |
| turn | one exchange of “message sent → provider”. A retry is requested by reference (`bb.sdk.threads.retry`), not by resending the message |
| dispatch | the checkpoint a message passes on its way to the provider. `bb.experimental_hooks` answers it: proceed, queue with a reason, or reject. The user’s send-now bypasses it by design. `PluginDispatchAttemptKind = "start-turn" \| "join-turn"` |
| event vs hook | events are announcements core makes, and the handler’s return value is ignored; hooks are questions core acts on. “The same split git draws between post-commit and pre-commit hooks” |
| environment | a thread’s execution context, binding a workspace directory to a host. An unmanaged one points at an existing directory; a managed one is removed once no unarchived thread uses it |
| host | the long-lived identity of the daemon of the machine that does the work. The server’s own host is `primaryHostId` |
| machine | what a machine provider provisions: an executor machine, distinct from the `host` record that identifies its daemon |
| provider | an overloaded term, always qualified in code. An agent provider (`bb.providers.register`) supplies the model runtime; an environment provider supplies a place for the thread; a machine provider supplies a machine; mention and instruction providers contribute to the UI and to the agent |
| `lifecycleOwnerThreadId` | an immutable, optional creation link, independent of the sidebar’s `parentThreadId`, the fork’s `sourceThreadId`, visibility and attribution. Archiving and deleting the owner recurse; ownership cannot be updated or removed |
| skill | a `skills//SKILL.md` directory injected into the agent’s threads as the plugin skills tier; `bb.skills` moves the root and `[]` opts out |
| status detail | the human-readable string beside `PluginRuntimeStatus`, assembled from the base status plus dev build problems, agent tool collisions and app bundle problems |
## Sources: where to go for the truth
[Section titled “Sources: where to go for the truth”](#sources-where-to-go-for-the-truth)
**Contracts and the API map**
* `packages/plugin-sdk/src/backend-contract.ts`: `BbPluginApi`
* `…/app-contract.ts`: the frontend · `host-contract.ts` · `rpc-contract.ts`
* `packages/plugin-api-map/src/surfaces.ts`: 7 groups, 46 surfaces; `product-map.tsx` (`SURFACE_NUMBERS`), `wireframes.tsx`, `anatomy-manifest.json`
* `docs/api_to_audit.md`: the registry of experimental members and the rule for dropping the prefix
**Runtime and build**
* `apps/server/src/services/plugins/plugin-runtime.ts` · `plugin-api.ts` · `routes/plugins.ts`
* `apps/app/src/lib/plugin-frontend.ts` · `PluginSlotMount.tsx`
* `apps/host-daemon/src/plugin-host-manager.ts` · `plugin-host-worker.ts`
* `packages/plugin-build/src/runtime-shims.mjs` · `scope-plugin-utilities.ts` · `plugin-artifact-meta.ts`
**bb’s own skills (the authoring documentation inside the repository)**
* `plugins/bb-guide/skills/bb-plugin-authoring/`, with `SKILL.md` and `references/`: `quickstart.md`, `backend-foundation.md`, `backend-events.md`, `backend-cli-agents.md`, `backend-sdk.md`, `backend-ui-lifecycle.md`, `backend-machines.md`, `frontend-core-slots.md`, `frontend-registration.md`, `frontend-components.md`, `frontend-hooks-and-ui.md`, `frontend-renderer-slots.md`, `providers.md`, `distribution.md`, `testing.md`
* `plugins/bb-guide/skills/bb-cli/`: `references/plugins.md`, `command-index.md`
* `plugins/bb-guide/skills/submit-a-plugin/`: how to submit a plugin to the community marketplace
**Documents and generated types**
* `docs/system-overview.md` · `lifecycle-diagrams.md` · `environment-provisioning.md` · `provider-bridge-protocol.md` · `provider-plugin-api.md` · `configuration.md` · `worktrees.md`
* `bundled-types`: after `npm install`, the authoritative surface sits at `node_modules/@get-bb/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts` (with doc comments), plus `-app.d.ts` and `-host.d.ts`. It is generated with rollup + rollup-plugin-dts and is not committed to git; if a plugin cannot resolve the SDK, run `pnpm exec turbo run build:types --filter=@get-bb/plugin-sdk`.
* Never answer a question about the API from a built bundle: the app bundle is minified, server and host have sourcemaps, but the contract is the declarations and the sources.
Facts marked “not verified”
No fact here was obtained by running code: all of it is static reading at the pinned release. Marked separately: the absence of a byte limit on plugin RPC; the spelling of `bb.sdk.hosts.experimental_*` and the presence of `threads.listRunning()` in the documentation; the behavior of `experimental_contributeEnv` and `experimental_contributeEnvHealth` (not one call site in the bundled plugins); `experimental_environments.recheck()` and `PLUGIN_CLI_OUTPUT_MAX_BYTES` (zero call sites); `bb.http.experimental_websocket` (server tests only); the ordering of the dispatch hook chain and the “single server lock” (a contract comment, not a read implementation); the escaping of secret keys in the path; the number of ACP dialects against the documentation; the caching of environment `availability`; the absolute path of the `bb` binary in the macOS build; and how (or whether) the mobile client loads plugin app bundles at all.
# Package anatomy
> The manifest, the dependency rule, the scaffold, dist/ and meta.json, plugin id derivation, and state on disk.
A plugin is an ordinary npm package, and this page covers its parts: the manifest, the dependency rule, the scaffold, the build artifacts and their metadata, how the plugin id is derived, and state on disk. Its manifest is its `package.json`: the top level is `.passthrough()`, while the `bb` block and the nested `bb.branding` are `.strict()`, so an unknown key inside `bb` breaks the manifest.
## The manifest: `bb.*` fields and `engines`
[Section titled “The manifest: bb.\* fields and engines”](#the-manifest-bb-fields-and-engines)
| Field | Req. | What it does | The catch |
| -------------------------------- | ------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | yes | npm identity and the only source of the plugin id | the convention is `bb-plugin-`; scoped `@acme/bb-plugin-` works too; the ids of all bundled plugins are reserved |
| `version` | yes | stamped into the artifacts | a managed install rejects a `dist/*.meta.json` whose `pluginVersion` disagrees with the manifest |
| `engines.bb` | no | a semver range against the application version; checked at runtime and when choosing an update | dev builds (`0.0.0`) are not checked, but say so in the result |
| `engines.bbPluginSdk` | no | the SDK range, read as a floor, not a ceiling | absent means a legacy manifest. `git:`/`npm:` installs refuse a plugin whose SDK is newer than the host or of another major; a path install reports status `incompatible` at load |
| `bb.name` | yes | the human name in Settings, the store and the sidebar | this is neither `name` nor the plugin id |
| `bb.description` | yes | one sentence: the card in Browse and the first paragraph of the page | keep it to \~140 characters and in sync with `PLUGIN_OVERVIEW.md` |
| `bb.branding` | yes | the plugin’s identity; a strict object | at least one of `icon` or `logo.light` is required |
| `bb.branding.icon` | one of | a bb icon name (`"Zap"`) or a plugin SVG path (`"./assets/icon.svg"`) | a namespaced glyph `"/"` is forbidden here; a plugin path must end in `.svg` |
| `bb.branding.logo` | no | `{ light, dark? }`: the larger identity for roomier places | `.svg/.png/.webp`; `dark` without `light` fails; `bb plugin build` rejects an SVG logo with a scripted vector; there is no auto-detection of a logo in the root |
| `bb.branding.experimental_icons` | no | `Record`: the plugin’s own icon dictionary for timeline rows and provider marks | names are `[a-z0-9-]` starting with a letter, ≤48 chars, ≤64 entries; files ≤32 KiB; a reject-only SVG validator (no `script/style/iframe/foreignObject/image/a`, no `on*`, no non-`#` href, no `xml:base`); a violation fails the plugin load and names the icon |
| `bb.server` | yes | the server entry | required even for a UI-only plugin: `plugins/scheduled-send/server.ts` is a deliberate stub. A path install loads the TypeScript directly, with no build |
| `bb.app` | no | the app entry → `dist/app.js` + `app.css` + `app.meta.json` | path and git installs build it at install time; an npm package must ship a ready `dist/` |
| `bb.host` | no, one | a full-trust Node 22 ESM entry → `dist/host.js` | called from its own server entry over typed host RPC; the daemon downloads it lazily and verifies the digest |
| `bb.skills` | no | moves the roots of auto-imported skills | the default is `skills/`; `[]` opts out. In the repository the value is always literally `["skills"]` (a directory, not names); 11 plugins ship `skills/` with no field at all |
| `bb.themes` | no | an array of `{ id, name, description?, css, codeTheme? }` → Settings → Appearance and `bb theme list` | the selectable id is `plugin::`; only loaded plugins contribute. No first-party plugin uses this field |
## `dependencies` versus `devDependencies`: an asymmetric rule
[Section titled “dependencies versus devDependencies: an asymmetric rule”](#dependencies-versus-devdependencies-an-asymmetric-rule)
**`dependencies`.** Everything imported at runtime that bb does not shim. `bb plugin build` inlines them; a git install resolves only this list. A package needed for the build but left in devDeps makes the plugin uninstallable from git, including the npm run of the packaged CLI itself, which runs under `NODE_ENV=production`.
`zod` is deliberately not shimmed: a slot for its namespace would cost +193 KB raw / +33 KB brotli in the host’s boot payload, so zod is bundled into every plugin and lives in `dependencies`.
One exception: a provider plugin keeps `@get-bb/plugin-sdk` in `dependencies`, because the host build bundles the `/provider-bridge` subpath from the plugin’s own installation.
**`devDependencies`.** Types, tooling, and every shimmed package. The single source of truth is `RUNTIME_SLOT_BY_SPECIFIER`. A shimmed package in `dependencies` means a second copy of a singleton next to the host’s.
`bb plugin types` re-pins the SDK and the type-only devDeps to the versions of the running bb; `bb plugin types --check` is the CI check: it writes nothing and exits non-zero on a mismatch.
```plaintext
// packages/plugin-build/src/runtime-shims.mjs — the full shim list
react · react-dom · react-dom/client · react/jsx-runtime · react/jsx-dev-runtime
@get-bb/plugin-sdk/app (+ legacy @bb/plugin-sdk/app)
@pierre/diffs · @pierre/diffs/react
@radix-ui/react-{alert-dialog, context-menu, dialog, dropdown-menu, hover-card,
menubar, navigation-menu, popover, select, tooltip}
sonner · vaul · clsx · tailwind-merge · class-variance-authority
@bb/shared-ui/icon · @bb/shared-ui/question-form-host
```
Two admission rules are written into the same file: (1) singleton semantics, meaning one React, one radix portal universe, `toast()` must reach the host’s toaster, `vaul` mutates `document.body`, and `@pierre/diffs` needs context identity; (2) libraries every plugin would otherwise duplicate, namely `tailwind-merge ^3`, `clsx ^2`, `cva ^0.7` and the shared-ui `Icon` (\~110 KB of the hugeicons map per copy).
## The `bb plugin new ` scaffold
[Section titled “The bb plugin new \ scaffold”](#the-bb-plugin-new-name-scaffold)
The command has no flags at all: no `--app`, no `--headless`, no `--yes`. The scaffold always includes the frontend; the README explains that a headless plugin means deleting `bb.app`, `app.tsx`, `components/`, `hooks/`, `lib/`. The directory is always named `bb-plugin-`, regardless of scope.
```plaintext
bb-plugin-/
├── package.json # manifest: version 0.1.0, type module, engines {bb, bbPluginSdk}
├── server.ts # server entry: a todo store on bb.storage.kv → RPC + CLI + skill
├── app.tsx # app entry: definePluginApp + one navPanel
├── tsconfig.json # strict, ES2022, moduleResolution bundler, paths {"@/*": ["./*"]}
├── components.json # shadcn config; the @bb registry is pinned to the release tag of the current bb
├── .gitignore # exactly "dist/\nnode_modules/"
├── README.md # a long author's README
├── PLUGIN_OVERVIEW.md # store copy: ≤4000 chars (target 700–1800), ## sections, no HTML or images
├── skills/
│ └── example-todos/SKILL.md # agent instructions for the bb command
├── components/ui/ # pre-vendored button, card, checkbox, dialog, input
│ ├── icon.tsx · icon-extended.tsx · icon-registry.ts
│ ├── motion.ts · overlay-trigger.ts · responsive-overlay.tsx · coarse-pointer-sizing.ts
│ └── hooks/ use-compact-viewport.tsx · use-media-query.ts · use-pointer-coarse.ts
├── hooks/useBrowserDimmingModal.ts
└── lib/ portal-scope.ts · utils.ts
```
Not created by the scaffold
A `types/` directory (the SDK comes from npm, and a test asserts its absence), a vitest config and a test file, `app.css`, `dist/`. The 0.41 changelog line “scaffold with a test that runs” does not match the current scaffold.
## `dist/` and `meta.json`
[Section titled “dist/ and meta.json”](#dist-and-metajson)
| Artifact | Files |
| ------------------- | ----------------------------------------------------- |
| server (always) | `dist/server.js`, `server.js.map`, `server.meta.json` |
| app (if `bb.app`) | `dist/app.js`, `app.css`, `app.meta.json` |
| host (if `bb.host`) | `dist/host.js`, `host.js.map`, `host.meta.json` |
All three are built with esbuild into an `mkdtemp` stage inside `dist/` and renamed in place.
packages/plugin-build/src/plugin-artifact-meta.ts
```jsonc
{
"sdkMajor": ,
"sdkVersion": "",
"artifactFormatVersion": 1,
"pluginId": "",
"pluginVersion": "",
"builtWith": { "bbVersion": "…", "pluginSdkVersion": "…" }
}
// host.meta.json adds a seventh field:
// "artifactDigest": ""
```
On the consuming side, `validatePluginArtifactMeta` requires `sdkMajor` to equal the current `PLUGIN_SDK_MAJOR`, and `pluginId`/`pluginVersion` to match the manifest; `builtWith.pluginSdkVersion` must be valid semver and equal to `sdkVersion`.
## Deriving the plugin id
[Section titled “Deriving the plugin id”](#deriving-the-plugin-id)
```plaintext
// packages/domain/src/plugin-id.ts — derivePluginId
1. if the package name is scoped, take the segment after "/"
2. strip the leading "bb-plugin-"
3. lowercase; any character outside [a-z0-9-] → "-"; trim "-" at the edges
4. an empty result throws
pluginIdSchema = /^[a-z0-9][a-z0-9-]*$/u
@acme/bb-plugin-Notes → notes
bb-plugin-simple-notes → simple-notes // directory plugins/docs, the only case of id ≠ directory
```
The id is the namespace for routes, storage, settings and CLI commands. `bb plugin new` additionally refuses the reserved names of core commands.
## State on disk
[Section titled “State on disk”](#state-on-disk)
```plaintext
/plugins//data.db its own SQLite (WAL, busy_timeout 5000)
/plugins//secrets/ secret settings, one file per key, + .http-token
/plugins//logs/plugin.log JSONL, rotated into plugin.log.1 at 5 MiB
/plugins/git/ · npm/ managed installs
/plugins/cache/git|npm/… materialized checkouts of the sources
/plugins/toolchain-* the downloaded esbuild/tailwind toolchain (cache it in CI)
/skills-generated/ the server-generated plugin-commands skill
/plugins//host-data the host side
/plugin-host-artifacts///host.mjs the artifact cache
```
`dataDir` defaults to `~/.bb/` for the packaged application; `pnpm dev` moves it to `~/.bb-dev//`, which is exactly why `bb.server.experimental_dataDir` exists instead of “guess `~/.bb`”.
# Runtime and lifecycle
> How bb loads, isolates, reloads and disposes each plugin entry, with the exact statuses, step orders and wire details.
This page is the reference for what bb does with a plugin while it runs: where each entry is loaded and how, the eight statuses, the load and dispose order, hot reload, services and schedules. [How a plugin works](/bb-plugin-atlas/architecture/) explains the model these details implement; read it first.
## Where each entry runs
[Section titled “Where each entry runs”](#where-each-entry-runs)
Four processes are separated by two contracts: `@bb/server-contract` between the clients and the server, and `@bb/host-daemon-contract` between the server and the daemons. Each plugin entry runs in a different process at a different level of trust.
**direct call**signal or push back**plugin code
Browser · Electron renderer — one JS realm, one origin · the user and the agent
#### plugin dist/app.js inside the bb React app
native import(), the same realm — not a sandbox
globalThis.\_\_bbPluginRuntime ← installPluginRuntime()
shims: react, radix portals, sonner, vaul, clsx…
1↓ `useRpc → POST /rpc` → Hono · /api/v1/\* (origin guard) · /internal/\* (daemon bearer)
#### bb CLI and the agent
$BB\_CLI · bb \
agent tools and skills
the tool set is frozen at session start
3↓ `POST /plugins//cli` → Hono · /api/v1/\* (origin guard) · /internal/\* (daemon bearer)
Server process (Node) — SQLite is the source of truth, 127.0.0.1 by default
#### Hono · /api/v1/\* (origin guard) · /internal/\* (daemon bearer)
/plugins/\/{assets, rpc, http, cli, token} · WebSocket /ws · POST /plugins/reload
the only exemption from the origin guard is /plugins/\/http/\*
2↓ `/ws: plugin-signal` → plugin dist/app.js inside the bb React app (signal back)
4↓ plugin server.ts — jiti.import, same process
#### plugin server.ts — jiti.import, same process
full trust: fs, net, child\_process, fetch
bb.rpc · bb.http · bb.realtime · bb.storage
bb.agents · bb.cli · bb.providers · bb.sdk
factory time-boxed at 30 s, dispose LIFO
6↓ `plugin.host.call` → plugin host worker — dist/host.js
#### PluginService
loadAll / loadOne / reload
status: 8 values
wireLookup(loaded)
generation = randomUUID() per artifact load
5↓ `thread commands` → provider subprocess — the agent
Host daemon — one per enrolled machine
#### plugin host worker — dist/host.js
fork(bb-plugin-host-worker.mjs)
stdio: ignore, ignore, pipe, ipc
sha256 verified before launch, ≤ 256 MiB
7↓ `experimental_emitSignal` → plugin server.ts — jiti.import, same process (signal back)
8↓ `bootstrap({ key, executor })` → MachineExecutor.exec(argv)
#### provider subprocess — the agent
bridge: JSON-RPC 2.0 over stdio
the delta assembler stays in the daemon
claude-code · codex · pi · acp-\*
Remote machine — provisioned by a machine provider
#### MachineExecutor.exec(argv)
the plugin owns the transport, stdin stays private
output flows through onOutput into the progress log
#### installed host daemon
enroll → server, its own WebSocket session
its own copies of the plugins’ host workers
9↓ `enrollment + WebSocket back` → Hono · /api/v1/\* (origin guard) · /internal/\* (daemon bearer) (signal back)
The three plugin entries are the filled boxes: bb.server in the server process, bb.app in the application’s realm, bb.host on the daemon. Each runs at a different level of trust.
The browser (or the Electron renderer) is one JS realm and one origin, shared by the user and the agent. The plugin’s `dist/app.js` is imported natively into the same realm, which is not a sandbox, and takes its shims from `globalThis.__bbPluginRuntime`, installed by `installPluginRuntime()`. The bb CLI and the agent reach the server over `$BB_CLI` and through agent tools and skills; the tool set is frozen at the start of the session.
The server process is Node with SQLite as the source of truth, bound to `127.0.0.1` by default. Hono serves `/api/v1/*` behind the origin guard and `/internal/*` behind the daemon’s bearer credentials: `/plugins//{assets, rpc, http, cli, token}`, the `WebSocket /ws`, and `POST /plugins/reload`. The plugin’s `server.ts` is loaded into that same process by `jiti.import`, at full trust (`fs`, `net`, `child_process`, `fetch`). `PluginService` owns `loadAll` / `loadOne` / `reload`, the eight status values, `wireLookup(loaded)`, and a `generation = randomUUID()` per artifact load.
The host daemon runs once per enrolled machine. It forks the plugin host worker from `dist/host.js` (`fork(bb-plugin-host-worker.mjs)`, stdio `ignore, ignore, pipe, ipc`, sha256 verified before launch, at most 256 MiB) and the provider subprocess that is the agent. The provider is bridged over JSON-RPC 2.0 on stdio, while the delta assembler stays in the daemon (`claude-code`, `codex`, `pi`, `acp-*`). A remote machine created by a machine provider is reached through `MachineExecutor.exec(argv)`: the plugin owns the transport, stdin stays private, and output flows through `onOutput` into the progress log. That machine runs its own installed host daemon, which enrolls with the server and holds its own WebSocket session and its own copies of the plugins’ host workers.
### Load mechanisms
[Section titled “Load mechanisms”](#load-mechanisms)
| Entry | Required | Process | Load mechanism |
| ----------- | --------------- | --------------------------------------------- | -------------------------------------------------------------------------------- |
| `bb.server` | yes | the server process, in-process | `jiti.import`, `moduleCache:false`, the SDK aliased to the host’s copy |
| `bb.app` | no | the same JS realm as the bb React application | native `import(url)` over HTTP; shims through one global |
| `bb.host` | no, exactly one | a forked child process on the host daemon | `fork()` + `import(pathToFileURL(...))`, the sha256 digest checked before launch |
The built files each entry loads from are listed in [Package anatomy](/bb-plugin-atlas/package/).
The server entry has no sandbox and no isolation. There is no `worker_threads`, no `node:vm` and no spawn in `apps/server/src/services/plugins/`. `jiti` is configured only with `moduleCache` and `alias`: no module allowlist and no import interception. Native addons are not supported, and the `ERR_DLOPEN_FAILED` error is annotated to say so.
The app entry shares the application’s realm but gets its own error boundary. `eval`, `new Function` and blob URLs are not used. The bundle is loaded from `/api/v1/plugins//assets/app.js?h=`, and its default export must carry the brand `__bbPluginApp === true`. Every registration is wrapped in `PluginSlotBoundary`, and a slot that crashed stays dead until the end of the session or until the plugin reloads.
The host entry is delivered by content address. The daemon downloads the artifact from the internal API (`GET /internal/plugins//host/`) and checks the length and the hash on download and on every reuse of the cache. It stores the artifact at `/plugin-host-artifacts///host.mjs`.
### The three entries side by side
[Section titled “The three entries side by side”](#the-three-entries-side-by-side)
| | `server.ts` | `app.tsx` | `host.ts` |
| -------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| Process | the bb server process | the application’s JS realm (browser or renderer) | a forked child on the host daemon |
| Module shape | `export default (bb: BbPluginApi) => …`, sync or async; the return value is ignored | `export default definePluginApp((app) => …)`, branded `__bbPluginApp` | `export default experimental_defineHostEntry({ contract, handlers, dispose? })` |
| Trust | full: the same process as the server; no isolation | full-trust same-origin page code, not a sandbox; isolated from crashes only | full on its own machine: Node 22, the same OS user as the agent |
| What it may import | any Node builtins and npm dependencies; the SDK and `better-sqlite3` are external | anything browser-side; shimmed packages come from the host; `zod` is bundled | pure JS is bundled whole; private `@bb/*` is forbidden at resolve and at load |
| target / format | esm · node · node22 · sourcemap | esm · browser · es2022 · minify (not in dev) | esm · node · node22 · sourcemap · no externals |
| How it talks to the others | serves `bb.rpc`/`bb.http`, publishes `bb.realtime`, calls the host through `bb.hosts.experimental_client` | `useRpc()` → POST; `useRealtime()` ← WebSocket; cannot call the host | answers `plugin.host.call`, sends `experimental_emitSignal` back to the server |
| Types | `import type { BbPluginApi } from "@get-bb/plugin-sdk"`, erased at load | `@get-bb/plugin-sdk/app` | `@get-bb/plugin-sdk/host` |
## Object states
[Section titled “Object states”](#object-states)
A plugin reads these states through `bb.sdk` and reacts to them through thread events.
| Object | States |
| ----------- | ---------------------------------------------------------------------------- |
| Thread | `pending`, `idle`, `starting`, `active`, `stopping`, `error` |
| Turn | `accepted` → `dispatched` → `started` → `completed`\|`failed`\|`interrupted` |
| Environment | `creating`, `provisioning`, `ready`, `error`, `destroyed` |
| Machine | `available` \| `setup-required` \| `unavailable` |
A retry is requested by reference (`bb.sdk.threads.retry`), not by resending the message. The environment transitions are drawn on [Environment providers](/bb-plugin-atlas/advanced/environments/).
## Wire details
[Section titled “Wire details”](#wire-details)
### The RPC contract
[Section titled “The RPC contract”](#the-rpc-contract)
server.ts
```ts
import { defineRpcContract } from "@get-bb/plugin-sdk";
import { z } from "zod";
export const rpcContract = defineRpcContract({
listIssues: { input: z.object({ filter: z.string() }),
output: z.object({ issues: z.array(z.string()) }) },
ping: { input: z.null(), output: z.object({ ok: z.literal(true) }) },
});
export default function plugin(bb: BbPluginApi) {
bb.rpc.register(rpcContract, {
listIssues: ({ filter }) => ({ issues: search(filter) }),
ping: () => ({ ok: true as const }),
});
}
```
The validator is Standard Schema v1, which zod 4 implements directly. `z.null()` on the input lets the frontend omit the argument. Method names are dotted segments of letters, digits, `-` and `_`. `useRpc()` calls `POST /plugins//rpc/` with `auth: local` (the origin guard) and `no-store`: the input schema runs, then the handler, then the output schema. Unknown methods answer `404 unknown_method`, invalid input `400 invalid_input`.
### HTTP routes and the token
[Section titled “HTTP routes and the token”](#http-routes-and-the-token)
```ts
// mount: /api/v1/plugins//http — an EXACT match;
// ":" and "*" are literal characters, not parameters and not wildcards
bb.http.route("POST", "/upload", async (context) => {
const body = await readRequestBody(context.req.raw);
return context.json({ ok: true }, 201);
}, { auth: "token" });
bb.http.experimental_websocket("/live", ({ request, url }) => ({
onOpen(socket) { socket.send("hello"); },
onMessage(socket, data) { /* … */ },
onClose({ code, reason }) { /* reload/disable → 1012 */ },
}), { auth: "local" });
```
`bb plugin token [--rotate]` issues 32 random bytes in hex from `/plugins//secrets/.http-token`, mode `0o600`, compared with `timingSafeEqual`. The token covers exactly one plugin and only its routes with `auth: "token"`; it is not a user session and not an identity.
The global origin guard and its single exemption
Every route under `/api/v1/*` passes through `browserRequestProblem`, except `/api/v1/plugins//http/*`, which the `PLUGIN_WIRE_HTTP_PATH` pattern excludes so that `auth: "token"` and `auth: "none"` can work at all. This is CSRF and origin protection, **not authentication**: a local non-browser client with no `Origin` header passes it. `/internal/*` requires the daemon’s bearer credentials, and the host artifact download lives there.
### Realtime
[Section titled “Realtime”](#realtime)
`bb.realtime.publish(channel, payload)` goes to `notifyPluginSignal` → `broadcastToAllClients` and reaches every client over the one shared `WebSocket /ws` as `{ type, pluginId, channel, payload }`. In V1 there are no server-side channel subscriptions: `useRealtime(channel, handler)` filters by `pluginId` and `channel` on the client. The payload therefore reaches the socket of every connected client, so **it is not a confidentiality boundary**. Signals are not replayed, so long-lived state must be re-checked on later transitions of `useRealtimeConnectionState()` into `connected`.
### CLI, agent tools and host RPC
[Section titled “CLI, agent tools and host RPC”](#cli-agent-tools-and-host-rpc)
`bb …` posts `{ argv, cwd?, threadId? }` to `POST /plugins//cli`; argv carries no command name. An agent tool is called inside the server process with `ctx: { threadId, projectId, signal }`, and reports an error as text rather than throwing.
The server calls a host entry with `bb.hosts.experimental_client({ contract }).call(method, input, { hostId, signal, timeoutMs })`. The wire is `plugin.host.call` / `.cancel` / `.dispose` over the daemon’s WebSocket. In the other direction, `context.experimental_emitSignal(name, payload)` arrives at `experimental_onSignal`. An idle worker is evicted after 5 minutes. Output caps, payload sizes and timeouts for the CLI and host RPC are in the numeric limits table on [Backend namespaces](/bb-plugin-atlas/backend/).
## Statuses
[Section titled “Statuses”](#statuses)
packages/server-contract/src/api/plugins.ts
```ts
export const pluginRuntimeStatusSchema = z.enum([
"starting", "running", "error", "incompatible",
"missing", "disabled", "degraded", "needs-configuration",
]);
// there is NO "healthy" value — the healthy state is called "running"
```
| Status | Cause |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `starting` | the row is enabled but not loaded yet |
| `disabled` | `!row.enabled`, or a load-hold is active for that source |
| `missing` | `stat(row.rootDir)` failed → “plugin directory not found: … (reinstall)” |
| `error` | the manifest does not parse; a host artifact problem; the factory threw or ran past the time-box; a service failed during activation |
| `incompatible` | `engines.bb` or `engines.bbPluginSdk` did not match; a packaged-builtin artifact problem |
| `needs-configuration` | `bb.status.needsConfiguration(msg)`, or a service that failed with `NeedsConfigurationError` |
| `degraded` | a background service did not stop within `serviceStopTimeoutMs` (5 s) |
| `running` | a successful load, and the status that is kept when a reload failed, with the detail “reload failed: …” |
`NeedsConfigurationError` is matched by name, so no runtime import is needed: `throw Object.assign(new Error(msg), { name: "NeedsConfigurationError" })`. The status resets on the next load. Neighbouring enumerations: a service state is `"running" | "backoff" | "stopped"`, a schedule’s `lastStatus` is `"running" | "ok" | "error"`, and an update outcome is `"current" | "update-available" | "pinned" | "incompatible" | "unavailable"`.
## Load and dispose order
[Section titled “Load and dispose order”](#load-and-dispose-order)
A reload loads the new instance first and disposes the old one afterwards. If the new factory throws, the previous instance keeps running, because dispose sits in `loadOne` after the factory.
**next step**failure path**plugin code runs here
loadOne
#### 1hold → identity → enabled
a held source or !row\.enabled → disabled
↓ stat(rootDir) → manifest
#### 2stat(rootDir) → manifest
no directory → missing
↓ engines and SDK range
#### 3engines and SDK range
no match → incompatible
↓ app bundle and host artifact
#### 4app bundle and host artifact
the digest is checked against host.meta.json
↓ branding assets
#### 5branding assets
the SVG validator can fail the load
↓ createPluginApi
#### 6createPluginApi
the bb object for this load
↓ jiti.import + factory
#### 7jiti.import + factory
time-boxed at 30 s
↓ dispose the previous instance
↓ `throw` → the factory throws (signal back)
#### 8dispose the previous instance
here, after the factory — not before it
↓ loaded.set(id, …)
#### 9loaded.set(id, …)
wireLookup starts seeing it
↓ handle.activate()
#### 10handle.activate()
providers, AI services, ports
↓ cron strings and services
#### 11cron strings and services
→ setStatus("running")
#### the factory throws
the previous instance stays alive; status running with the detail “reload failed: …”
disposePluginInstance — strict order
#### 1closeWebSockets
code 1012
↓ disposePluginHost
#### 2disposePluginHost
{ pluginId, generation }
↓ abortPluginToolCalls
#### 3abortPluginToolCalls
"plugin-disposed"
↓ interruptInteractions
#### 4interruptInteractions
reason: plugin-disposed
↓ stopServices
#### 5stopServices
5 s → degraded
↓ onDispose
#### 6onDispose
LIFO; one failing hook does not stop the rest
↓ drainInvocations
#### 7drainInvocations
wait for in-flight work, log after 5 s
↓ close better-sqlite3
#### 8close better-sqlite3
every tracked handle
↓ finally handle.invalidate()
#### 9finally handle.invalidate()
any late bb.\* call → PluginContextStaleError
The previous instance is disposed after the new factory has run, not before it, so a failing reload leaves the running plugin alone. One failing dispose hook does not stop the rest of the cleanup.
`loadOne`, in order:
| # | Step | Note |
| -- | -------------------------------- | ---------------------------------------------- |
| 1 | hold → identity → enabled | a held source or `!row.enabled` → `disabled` |
| 2 | `stat(rootDir)` → manifest | no directory → `missing` |
| 3 | `engines` and the SDK range | no match → `incompatible` |
| 4 | app bundle and host artifact | the digest is checked against `host.meta.json` |
| 5 | branding assets | the SVG validator can fail the load |
| 6 | `createPluginApi` | the `bb` object for this load |
| 7 | `jiti.import` + the factory | time-boxed to 30 s |
| 8 | dispose of the previous instance | here, and only here: after the factory |
| 9 | `loaded.set(id, …)` | `wireLookup` starts seeing it |
| 10 | `handle.activate()` | providers, AI services, ports |
| 11 | cron strings and services | → `setStatus("running")` |
`disposePluginInstance` runs in a strict order:
| # | Step | Note |
| - | ----------------------------- | ------------------------------------------- |
| 1 | `closeWebSockets` | code 1012 |
| 2 | `disposePluginHost` | `{ pluginId, generation }` |
| 3 | `abortPluginToolCalls` | `"plugin-disposed"` |
| 4 | `interruptInteractions` | `reason: plugin-disposed` |
| 5 | `stopServices` | 5 s → `degraded` |
| 6 | `onDispose` | LIFO; one error does not stop the rest |
| 7 | `drainInvocations` | wait for in-flight work, log after 5 s |
| 8 | close `better-sqlite3` | every tracked handle |
| 9 | `finally handle.invalidate()` | any late `bb.*` → `PluginContextStaleError` |
One failing dispose hook does not stop the rest of the cleanup. `disposeOne` additionally removes the host artifact and withdraws shared port declarations.
The factory is time-boxed to 30 s: `DEFAULT_LOAD_TIMEOUT_MS = 30_000`, applied by `runFactoryTimeBoxed`. Key registrations must be unique within one run of the factory: settings, routes, RPC methods, services, schedules, the CLI registration, tools, and instruction and mention providers. Listeners (`bb.events.on`, `settings.onChange`, `bb.onDispose`) are additive.
`onDispose` hooks run in reverse registration order, and they are the place to clear timers and close connections. After `handle.invalidate()` every method of a stored `bb` throws `PluginContextStaleError`, which is why `bb` must not be kept in module-level state.
HTTP and WS routes, RPC methods, agent tools, hooks, mention providers, environment and machine providers and schedules cannot be unregistered one at a time. They live on the handle and become unreachable when `loaded` changes, because every lookup goes through `wireLookup`, which reads only that map. Cron strings stay in `plugin_schedules`, but the sweep skips any whose plugin is not in `loaded`.
A failed activation rolls back three things. `rollbackGeneration?.()` restores the previous mutable-root epoch and puts back the evicted CJS entries; `discardCandidateHandle` closes the failed factory’s database handles; and the previous instance was never destroyed. The caller gets “\ (the previous instance is still running)”. Managed updates have a second rollback: a failed activation restores a state snapshot, and `pluginApplyUpdateResultSchema.outcome` includes `"rolled-back"`.
### Three meanings of “generation”
[Section titled “Three meanings of “generation””](#three-meanings-of-generation)
| Meaning | What it is |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1. the module-cache epoch (server) | `registerHooks` appends `?bbPluginLoad=.` to every `file:` URL under the watched plugin root and evicts `require.cache`; only for `path` and `builtin` sources. This, and not jiti’s cache, forces the whole module graph to be re-evaluated |
| 2. the host artifact identity | `randomUUID()` per host artifact load; worker dispose is addressed by it, and worker-exit and signal delivery are gated on it |
| 3. the frontend mount generation | a monotonic per-client counter that increases on every re-interpretation and arrives in a content script as `context.generation` |
## Hot reload: `bb plugin dev`
[Section titled “Hot reload: bb plugin dev”](#hot-reload-bb-plugin-dev)
Both paths use `createPluginDevLoop`: a 300 ms debounce, ignoring `dist`, `node_modules`, `.git`; the cycle is `targets()` → build app → build host → reload.
* `bb plugin dev [path]` watches `fs.watch(rootDir, { recursive: true })` and reloads over `POST /plugins/reload?id=…`. The directory must already be installed, and the app bundle is built without minification.
* The server watches its own plugin sources only when `deps.watchBuiltinPluginSources` is set: an in-process rebuild, then `disposeOne` + `loadOne`. A watcher failure logs “source watcher failed; hot reload is off until the server restarts”.
* Open pages pick up the new UI over the `plugins-changed` WebSocket notification; a plugin whose bundle `hash` did not change is skipped. Dev build problems are folded into the status detail, labelled `frontend bundle build failed` / `host bundle build failed`.
## Background services and schedules
[Section titled “Background services and schedules”](#background-services-and-schedules)
A service starts after the factory has finished and must resolve when its `signal` is aborted. A failure restarts it with bounded exponential backoff: base 1 s, maximum 60 s, health reset after 5 minutes. An error outside the `start` promise (an unhandled `'error'` on an EventEmitter, a throw in a timer, a detached rejection) also counts as a failure. Attribution goes through `AsyncLocalStorage`, so one plugin’s uncaught throw does not kill the server process.
A schedule is a 5-field cron in the server’s local time, backed by a durable row keyed `(pluginId, name)` in `plugin_schedules`. A periodic sweep claims it by compare-and-swap on `next_run_at`, but only while the plugin is loaded. A throw from a schedule lands in `last_status`/`last_error` and shows up in `bb plugin list`; it does not change the plugin’s status.
# Your first plugin
> Create the scaffold bb generates, install it, watch it work from the page, the CLI and the agent, and change it while bb reloads it.
In this tutorial you create the plugin `bb plugin new` generates, install it into your bb, use it from three places, and change it while bb reloads it. The scaffold is a working todo list with one store and three ways in: a page in the sidebar, the `bb hello` command, and a skill that tells the agent to use that command. Every part of it maps onto a part of [How a plugin works](/bb-plugin-atlas/architecture/), which this tutorial assumes you have read.
You need a running bb 0.43 and npm. Keep the [UI zone map](/bb-plugin-atlas/map/) open alongside if you want to see where each surface sits in the window.
## 1. Create the plugin
[Section titled “1. Create the plugin”](#1-create-the-plugin)
```console
$ bb plugin new hello
Created bb-plugin-hello/ (bb-plugin-hello).
Installed dependencies (npm install).
Next steps:
cd bb-plugin-hello
bb plugin install .
```
The command installs the dependencies itself. If that step fails, the output lists `npm install --include=dev` among the next steps; run it before step 5.
The command takes no flags. The package is named `bb-plugin-hello`, and bb derives the plugin id `hello` from that name by dropping the `bb-plugin-` prefix. The id appears in the page’s URL, in the storage path and as the CLI command name.
## 2. Find the entries in the scaffold
[Section titled “2. Find the entries in the scaffold”](#2-find-the-entries-in-the-scaffold)
The scaffold holds four files that make up the plugin and three directories of UI code you own.
* `package.json` is the manifest: the npm identity plus the `bb` block, which names `server.ts` and `app.tsx` as the two entries.
* `server.ts` is the server entry: the todo store, four RPC methods and the `bb hello` command.
* `app.tsx` is the app entry: the “Example todos” page in the sidebar.
* `skills/example-todos/SKILL.md` tells the agent which `bb hello` commands exist and when to use them.
* `components/ui/`, `hooks/` and `lib/` hold vendored shadcn components and helpers, which are yours to change.
Every generated file, the manifest fields and the dependency rule are in [Package anatomy](/bb-plugin-atlas/package/).
## 3. Read the server entry
[Section titled “3. Read the server entry”](#3-read-the-server-entry)
Open `server.ts`. Stripped to its structure, it does this:
```ts
// server.ts, abridged
export const rpcContract = defineRpcContract({
todos_list: { input: z.null(), output: z.object({ todos: z.array(todoSchema) }) },
todos_add: { input: z.object({ title: z.string().trim().min(1).max(200) }), output: todoSchema },
// todos_set_done, todos_remove
});
export default async function plugin(bb: BbPluginApi) {
const writeTodos = async (todos: Todo[]) => {
await bb.storage.kv.set("todos", todos);
bb.realtime.publish("todos-changed", { count: todos.length });
};
bb.rpc.register(rpcContract, { todos_list: …, todos_add: …, /* … */ });
bb.cli.register({ name: "hello", commands: [/* list, add, done, undo, remove */], async run(argv) { … } });
bb.onDispose(() => { bb.log.info("disposed"); });
}
```
The default export is the factory from [How a plugin works](/bb-plugin-atlas/architecture/). It registers four RPC methods and one CLI command, and both call the same helpers over `bb.storage.kv`. Every write ends with `bb.realtime.publish`, which tells every open page that the list changed. The contract is exported so that `app.tsx` can import its type.
## 4. Read the app entry
[Section titled “4. Read the app entry”](#4-read-the-app-entry)
Open `app.tsx`. The page asks the server for the list over RPC and asks again whenever the signal arrives:
```tsx
// app.tsx, abridged
function useTodos() {
const rpc = useRpc(); // typed by the server's contract
// rpc.call("todos_list") on mount, and again on every "todos-changed" signal
useRealtime("todos-changed", refetch);
…
}
function TodosPage() {
const { rpc, todos, refetch } = useTodos();
…
}
export default definePluginApp((app) => {
app.slots.navPanel({
id: "example-todos", title: "Example todos", icon: "ListTodo",
path: "example-todos", component: TodosPage, // route /plugins/hello/example-todos
});
});
```
`app.slots.navPanel` adds a row to bb’s sidebar and gives the plugin a full page. The file imports only the type of `rpcContract`, so no server code reaches the browser bundle. React and the SDK are not bundled either: bb supplies them at runtime, which is why `app.tsx` works only inside bb.
## 5. Install it and use it from three places
[Section titled “5. Install it and use it from three places”](#5-install-it-and-use-it-from-three-places)
```bash
cd bb-plugin-hello
bb plugin install . # a path install: bb loads server.ts directly and builds only app.tsx
bb plugin dev # leave running: rebuilds and reloads on every save
```
`bb plugin install` warns that plugins run with full trust and asks for confirmation; answer `y`. Without a terminal to ask in, it refuses unless you pass `--yes`. After the install, `bb plugin list` shows `hello@0.1.0 running`.
Now use the plugin the three ways the scaffold wires up:
1. In bb, open **Example todos** in the sidebar and add a todo.
2. In a terminal, run `bb hello add "Ship it"`. The page shows the new todo without a reload, because the command’s write published `todos-changed`.
3. In a thread, ask the agent to add a todo. The skill tells it to run `bb hello add`, so the change takes the same path as your terminal command.
**direct call (steps 1–4)**realtime signal back**code you wrote
#### app.tsx · navPanel “Example todos”
/plugins/hello/example-todos
rpc.call("todos\_add", …)
useRealtime("todos-changed", refetch)
1↓ `rpc.call(…)` → POST /api/v1/plugins/hello/rpc/\
#### POST /api/v1/plugins/hello/rpc/\
input validated by the schema
{ ok:true, result } | { ok:false, error }
↓ server.ts — jiti, same process
#### server.ts — jiti, same process
bb.settings.define({ showDone })
bb.rpc.register(rpcContract, …)
bb.cli.register({ name: "hello" })
bb.realtime.publish("todos-changed")
bb.onDispose(() => …)
↓ `read and write` → bb.storage.kv → plugin\_kv in bb.db
4↓ `publish to every client` → WebSocket /ws — { type:"plugin-signal", pluginId:"hello", channel:"todos-changed", payload } (signal back)
#### bb hello add "Ship it"
run() executes on the server, not in the CLI
2↓ `CLI → server` → POST /api/v1/plugins/hello/cli
#### POST /api/v1/plugins/hello/cli
{ argv, cwd?, threadId? }
↓ server.ts — jiti, same process
#### the agent in the thread
skills/example-todos/SKILL.md is injected into the thread
3↓ `the same command` → the same bb hello command
#### the same bb hello command
the agent never reaches the store directly
↓ server.ts — jiti, same process
#### bb.storage.kv → plugin\_kv in bb.db
key "todos", JSON ≤ 256 KB
beside it: \/plugins/hello/
#### WebSocket /ws — { type:"plugin-signal", pluginId:"hello", channel:"todos-changed", payload }
ephemeral: nothing is stored and nothing is replayed; the client does the filtering, so this is not a confidentiality boundary
↓ `every page refetches` → app.tsx · navPanel “Example todos” (signal back)
1 the page over RPC · 2 the CLI · 3 the agent running the same command · 4 the signal back to every client. State lives only on the server: the page, the CLI and the agent are three clients of one server.ts.
All three paths end in the same `server.ts`, and none of them touches the store directly. `bb plugin logs hello -f` shows what the plugin logs, and `bb plugin list` shows its status.
## 6. Change it
[Section titled “6. Change it”](#6-change-it)
Add a `count` command. In `server.ts`, add an entry to the `commands` list and a case to the `switch` inside `run`:
```ts
commands: [ /* … */ { name: "count", summary: "Count todos", usage: "bb hello count" } ],
case "count": {
const todos = await listTodos();
return { exitCode: 0, stdout: String(todos.length) };
}
```
Save the file. `bb plugin dev` prints `reloaded hello`, and `bb hello count` prints the number. `bb plugin logs hello` now shows `loaded` from the new instance before `disposed` from the old one: the reload order from [How a plugin works](/bb-plugin-atlas/architecture/).
bb reads the `commands` list without running your code and adds `bb hello count` to the skill it generates for plugin commands. Two texts are yours to update by hand: the `usage` string in `server.ts`, which `bb hello --help` prints, and the command table in `skills/example-todos/SKILL.md`, the skill the agent loads for this plugin.
## When something does not work
[Section titled “When something does not work”](#when-something-does-not-work)
| Symptom | Cause | Fix |
| ----------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `@get-bb/plugin-sdk` does not resolve | the SDK types are not installed | run `npm install --include=dev`; inside the bb monorepo, `pnpm exec turbo run build:types --filter=@get-bb/plugin-sdk` |
| portals, toasts or focus behave oddly | a shimmed package such as React or radix is in `dependencies`, so a second copy is bundled | move every package listed in `RUNTIME_SLOT_BY_SPECIFIER` to `devDependencies`; `zod` stays in `dependencies` |
| the plugin stays in `needs-configuration` | the plugin called `bb.status.needsConfiguration(...)`, or a service failed with `NeedsConfigurationError` | fix the configuration, then run `bb plugin reload hello` |
| the SDK is older than the running bb | the scaffold pinned the SDK of the bb that created it | run `bb plugin types`; `bb plugin types --check` does the same check in CI without writing |
## Where to go next
[Section titled “Where to go next”](#where-to-go-next)
* To decide where your own idea goes in the window or in the agent, read [Choosing a surface](/bb-plugin-atlas/choose/).
* To store a secret, declare a setting with `secret: true`; its value never reaches the app entry. The [`bb.settings`](/bb-plugin-atlas/backend/settings/) card has the details.
* To render a component inside the agent’s reply, see the [`messageDirective`](/bb-plugin-atlas/frontend/messageDirective/) slot.
* To ask the user a question from the CLI or a tool, see [`bb.ui`](/bb-plugin-atlas/backend/ui/) and the [`pendingInteraction`](/bb-plugin-atlas/frontend/pendingInteraction/) slot, and the propose-and-confirm pattern in [Trust model](/bb-plugin-atlas/trust/).
# Testing: the harnesses
> createFakePluginHost, loadPluginApp and renderSlot, the fake SDK and the host entry harness, and where they stop being faithful.
This page covers four test harnesses, each of which runs without a running bb: `createFakePluginHost` (backend), `renderSlot` + `loadPluginApp` (frontend), `createFakeSdk`, and `experimental_createHostEntryHarness` (host entry). It shows what each harness drives and exposes, and where the harnesses stop being faithful.
## Backend: `createFakePluginHost`
[Section titled “Backend: createFakePluginHost”](#backend-createfakepluginhost)
The harness a server entry author needs first: it hands you a real `bb` object and a remote control for it, applying the same rules production does, including the refusal to register a provider or an AI service for a plugin without `bb.host`, and the refusal of a namespaced glyph the manifest never declared.
```ts
import { createFakePluginHost } from "@get-bb/plugin-sdk/testing";
import plugin from "./server";
const { bb, harness } = createFakePluginHost({
pluginId: "hello", // defaults to "test-plugin"
settings: { showDone: false }, // as if saved before this load, secrets included
sdk: { threads: { list: async () => [] } }, // bb.sdk stubs; extend with harness.sdk.stub(...)
experimental_hostEntry: true, // whether the manifest declares bb.host; default true
});
try {
await plugin(bb); // an ordinary call of your factory
// drive the surfaces "as the host would"
await expect(harness.behavior.callRpc("todos_add", { title: "Ship it" }))
.resolves.toMatchObject({ title: "Ship it" });
const cli = await harness.behavior.runCli(["list"]);
expect(cli.exitCode).toBe(0);
// and read back what the plugin did
expect(harness.registrations.rpcMethods).toContain("todos_add");
expect(harness.realtimeSignals.at(-1)?.channel).toBe("todos-changed");
expect(harness.logEntries[0]).toMatchObject({ level: "info", message: "loaded" });
} finally {
await harness.lifecycle.dispose(); // runs onDispose and clears the temporary storage
}
```
### What `harness.behavior` can do
[Section titled “What harness.behavior can do”](#what-harnessbehavior-can-do)
`callRpc(method, input?)` · `runCli(argv, ctx?)` · `fetchHttp(...)` · `experimental_openWebSocket(...)` · `runService(name)` · `runSchedule(name)` · `callAgentTool(...)` · `resolveAgentConfiguration(context)` · `resolveProviderEnv(...)` / `resolveProviderEnvHealth(...)` · `setSettings(values)` · `submitInteraction(id, value)` / `cancelInteraction(id)` · `experimental_emitHostSignal(...)` / `experimental_emitHostWorkerExit(hostId)`.
`harness.lifecycle` gives `reload(factory)` and `dispose()`; `harness` itself inherits inspection, behavior and lifecycle, so `harness.callRpc(...)` and `harness.behavior.callRpc(...)` are the same thing.
### What inspection exposes
[Section titled “What inspection exposes”](#what-inspection-exposes)
`pluginId` · `logEntries` · `realtimeSignals` · `needsConfigurationMessages` · `recheckCount` · `sdk` (a `FakeSdkHarness`) · `registrations` (routes, RPC methods, schedules, services, CLI, tools, mention providers, and `experimental_publishedRpcMethods` with the descriptions and JSON schemas of discoverable methods) · `sharedPortDeclarations` · `experimental_hostRpcCalls` · `pendingInteractions`.
The two remaining harnesses. `createFakeSdk({ pluginId, overrides })` is a separate recording double of `bb.sdk`: calls are recorded after the normalization the server applies, and a call with no stub throws naming the exact path to stub.
`experimental_createHostEntryHarness(entry, options?)` from `@get-bb/plugin-sdk/testing/host` runs a host entry in-process through the same boundaries the daemon uses (validation, the JSON transport, cancellation, the lifecycle and the response size ceiling) and gives you `experimental_call`, `experimental_getSignals()`, `experimental_getRetainedWorkerLeaseCount()`, `experimental_lifecycleSignal` and `experimental_dispose()`. Process crashes remain the business of the daemon’s integration tests.
## Frontend: `loadPluginApp` and `renderSlot`
[Section titled “Frontend: loadPluginApp and renderSlot”](#frontend-loadpluginapp-and-renderslot)
```tsx
// plugins/tasks/app.test.tsx (abridged) — a frontend slot driven by realtime
// @vitest-environment jsdom
import { cleanup, waitFor } from "@testing-library/react";
import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app";
const app = await loadPluginApp(() => import("./app")); // THE THUNK FORM ONLY!
afterEach(cleanup);
const Accessory = app.navPanels[0]?.experimental_sidebarAccessory;
const slot = renderSlot({ component: Accessory! }, {}, {
rpc: { sidebarOpenTaskCount: () => ({ openTaskCount }) },
});
expect(await slot.findByText("12")).toBeDefined();
await slot.behavior.emitRealtime("tasks:changed", { taskId: "…", projectId: "…" });
await waitFor(() => expect(slot.getByText("13")).toBeDefined());
expect(slot.inspection.rpcCalls.filter(({ method }) => method === "sidebarOpenTaskCount"))
.toHaveLength(3);
```
### The three steps of the frontend harness
[Section titled “The three steps of the frontend harness”](#the-three-steps-of-the-frontend-harness)
* `installTestPluginRuntime()` fills `globalThis.__bbPluginRuntime.pluginSdkApp`. It must run before `app.tsx` is evaluated, because that module binds the runtime on import.
* `loadPluginApp(source)` installs the runtime, resolves the definition, checks the `__bbPluginApp` brand and runs the same validating collector the host runs, with the same error texts. Pass the thunk `() => import("./app")` so the plugin module is evaluated after the installer.
* `renderSlot(registration, props, options?)` takes only `{ component }`, so any component reachable from a registration works, including `navPanels[0].experimental_sidebarAccessory`.
`RenderSlotOptions`: `rpc` (input and results travel through strict JSON, as they would over the wire; a method with no handler rejects with “no rpc handler for …”), `settings`, `context`, `realtimeConnectionState`, `composer`, `sidebarThreads`, `providers`, `codeTheme`, `branchesState`, `checkoutState`, `sidebarPullRequests`, `openThreadPanel`, `openUrl`, `openFilePreview`, `openFileExternally`, `experimental_openFixedTab`, `experimental_fixedTabTarget`.
### What the harness hands back
[Section titled “What the harness hands back”](#what-the-harness-hands-back)
* `behavior`: `emitRealtime` (wrapped in `act`, with the payload passed through JSON exactly as `bb.realtime.publish` does), `setRealtimeConnectionState`, `setComposerText`, `setComposerScope`.
* `inspection`: `rpcCalls`, `navigateCalls` (a discriminated union over all nine `BbNavigate` methods), `experimental_fixedTabOpenCalls`, `sidebarActionCalls`, `composer` (text, scope, attachments, effects, locks, quotes, mentions, focuses, submits, selections).
* `lifecycle`: `rerender(ui)`, `unmount()`.
* Content scripts get their own harness, `mountPluginContentScripts(app, { pluginId, generation?, omitExperimentalThreadRowStatus? })`; the last flag simulates an older host with no row-status API. Mount order is host-exact: a mount that throws unwinds the already-mounted ones in reverse and rethrows.
Limits of fidelity
The host components in the harness are stubs that record their props into data attributes, not the real engines: `bb-thread-chat`, `bb-markdown`, `bb-new-thread-composer`, `bb-provider-model-picker`, `bb-branch-picker`, `bb-permission-mode-picker`, `bb-source-code`, `bb-diff`. `useComposerView()` always reports `layout: "expanded"` and `run: { isRunning: false, isSubmitting: false }`; `experimental_useSidebarThreadSplit` always reports `isAvailable: true` and `layout: null`; `experimental_useSidebarThreadPullRequest` never reports `isLoading: true`; and the harness composer has no send pipeline, so `experimental_submit` only records the options and clears the draft. Bridges have their own kit: `experimental_runBridgeConformance` + `experimental_captureBridgeJsonRpcOutput().takeMessages`, with working examples in `examples/plugins/echo-provider/provider-bridge.{conformance,stream,parity}.test.ts`.
# Trust model
> What a plugin can actually reach, what a secret actually is, and what that means for plugin design.
In bb the security boundary is the decision to install a plugin: there is no runtime isolation, and the repository and the code agree on this. This page covers what the server entry and the frontend can reach, what a plugin secret is on disk, how the agent relates to plugin data, and what follows for plugin design. It states only what the sources confirm.
How the repository puts it
“Plugins are full-trust code running inside the bb server process: they can read all local bb data, including other plugins’ secrets” (`docs/configuration.md`).
Related passages: “Install and update run third-party code with full trust… Pass `--yes` only after the user confirms that exact source and version”; “Installs prompt for confirmation (plugins are full-trust code)”; `bb.host` is a “full-trust Node 22 ESM entry”; content scripts are “full-trust, same-origin page code — not a security sandbox… install only plugins you trust”. “Sandboxed install check in registry CI” appears in `docs/plugin-marketplace-plan.md` as a plan item: there is no runtime isolation.
## What the server entry can reach
[Section titled “What the server entry can reach”](#what-the-server-entry-can-reach)
| Resource | Access | Confirmation |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| Node builtins (`fs`, `net`, `child_process`, `fetch`) | unrestricted | loaded through `jiti.import` with only `moduleCache`/`alias`; no `vm`, no worker, no fork in `apps/server/src/services/plugins/` |
| The server’s data directory | handed over directly | `bb.server.experimental_dataDir` |
| Another plugin’s `data.db` | yes: it is an ordinary file at a path the plugin was told | [State on disk](/bb-plugin-atlas/package/) plus unrestricted `fs` |
| Another plugin’s secrets | yes: plaintext files, mode `0600`, same user | `packages/secret-storage/src/secret-file.ts`; stated outright in `configuration.md` |
| The shared `bb.db` | the same `DbConnection` is passed into `createPluginApi`; `kv`/`settings` scope by `pluginId`, but that is a convention of those helpers, not an enforced boundary | `plugin-runtime.ts`, `plugin-api.ts` |
| The full server SDK | `bb.sdk`, bound to this server over loopback | `plugin-api.ts` |
| The network | unrestricted | ordinary `fetch`/`net` |
The API object does perform liveness and name-ownership checks, but never permission checks: `assertLive()` and ownership of a provider / tool / AI service / environment / machine id. Path-traversal checks exist only for declared assets: `readPluginProviderIcon` refuses outside `rootDir`, and `resolveInside` guards the artifact cache directories. Every limit that exists is about liveness and resilience, not security.
A contradiction in the product surface map
The product surface map claims that storage lets a plugin “read and write only its own namespace; other plugins cannot see it”.
That is true of the `bb.storage` API. It is not an enforced isolation boundary, and the authoritative statement remains `docs/configuration.md`.
## What the frontend can reach
[Section titled “What the frontend can reach”](#what-the-frontend-can-reach)
The same origin, the same JS realm, no iframe, no Worker, no shadow root, no CSP.
* The bundle is `import()`ed straight into the application’s module graph.
- Arbitrary network: yes. Nothing wraps `fetch`; `useRpc` simply uses the global one.
- There is no CSP on the application document or on `app.js`/`app.css`: the asset route sets only cache, content type and encoding. CSP appears only on plugin images and SVGs (`default-src 'none'` + `nosniff`), on file and HTML previews, and on thread data.
- `sandbox: true` / `contextIsolation: true` in Electron isolate the renderer from Node and from preload. They do not isolate plugin JS from application JS inside the page.
- The only runtime guards here are integrity guards, not protective ones: error boundaries, a 10-second mount time-box for content scripts, and `foreign-dom-mutation-guard.ts`, which suppresses mutations of the host’s React nodes.
- The one thing a plugin frontend cannot obtain is a secret setting: `useSettings()` returns non-secret values only, and the settings view reports secrets as `{ set: boolean }`.
## Secrets on disk
[Section titled “Secrets on disk”](#secrets-on-disk)
```ts
// packages/secret-storage/src/secret-file.ts — 71 lines of file operations,
// ZERO runtime dependencies: no keytar, no Electron safeStorage, no libsecret
readOrCreateSecretFile({ bytes, dataDir, encoding, fileName })
// create the directory; read and trim if non-empty; otherwise randomBytes(bytes)
// write with flag "wx", mode 0o600; on EEXIST re-read (no race)
writeSecretFile(path, value)
// create the directory; write ..tmp with mode 0o600; rename.
// THE VALUE IS WRITTEN AS IS, UTF-8 — THIS IS NOT ENCRYPTION.
deleteSecretFile(path)
```
So: a `secret: true` plugin setting is **plaintext on disk in mode `0600`, protected only by POSIX permissions**. The one place where bb encrypts uses this package only for the key: `environment-storage.ts` performs AES-256-GCM with a 32-byte hex key from `/machine-environment-key` and AAD of `[projectId, name]`. Plugin settings do not go down that path. The `secrets` plugin is a different thing: it stores nothing itself, asks for credentials through an interactive renderer, and funnels them into a user-chosen dotenv file with mode `0600`.
Not verified
Whether setting keys and plugin ids are validated and escaped before being joined into `/plugins//secrets/`. The server-side path construction (`plugin-settings.ts`) escapes nothing, unlike `safePluginSegment` (`encodeURIComponent`), which the daemon applies to host paths. Check the validation higher up the stack before asserting anything either way.
## How the agent relates to plugin data
[Section titled “How the agent relates to plugin data”](#how-the-agent-relates-to-plugin-data)
The agent is a provider process that the host daemon launches on an executor machine. Plugin state lives under the server’s data directory. In an ordinary single-machine deployment (a packaged or desktop application), the server, the daemon and the agent’s shell run on the same machine under the same OS user. Under those conditions:
* `/plugins//secrets/*` and `.http-token` are plaintext files in mode `0600`. Mode `0600` protects them from other users of the machine. It does not protect them from a process running as the owner, and that includes any shell command the agent runs.
* The plugin’s `data.db` and `bb.db` are likewise ordinary files with no further protection.
* The server’s public API is unauthenticated: “The public API is unauthenticated and permits command execution and file reads, so never expose a wildcard-bound server to an untrusted network”.
The default listener is `127.0.0.1`, and the origin guard on `/api/v1` is CSRF protection against browsers, not authentication: a local non-browser client with no `Origin` passes it.
It follows that an agent with shell access on the server’s machine, running as the bb user, can read plugin data and secrets and call the local API. Nothing in bb’s design prevents it, and nothing promises otherwise. The measures that do exist sit above the filesystem: the agent’s permission modes, environment isolation (`docs/environment-provisioning.md`, `plugins/environment-modal-sandbox`), and running the agent on a different machine from the server. In that case the server’s plugin state is out of reach of that shell, while the host-side state (`/plugins//host-data`) sits on the agent’s machine and is reachable there.
## Consequences for plugin design
[Section titled “Consequences for plugin design”](#consequences-for-plugin-design)
### The propose-and-confirm pattern
[Section titled “The propose-and-confirm pattern”](#the-propose-and-confirm-pattern)
Since secrets and data are reachable by any code already running as the same user, secrecy cannot protect a plugin; its safety rests on explicit human confirmation:
* The agent’s tool proposes an action, and `bb.ui.requestInput` executes it behind a form the user confirms, the way `plugins/secrets` does it.
* Forms raised from `execute` detach from the agent’s turn, so the confirmation does not break the request timeout and the result arrives as a system message.
* bb stores neither the form payload nor the submitted value; `describeSubmission` decides what reaches the transcript. That is the only way to keep a secret out of the history.
* An `auth: "token"` token covers one plugin, carries no identity and rotates. Use it as separation of duties, not as user authentication.
### The untrusted-input rules the SDK states itself
[Section titled “The untrusted-input rules the SDK states itself”](#the-untrusted-input-rules-the-sdk-states-itself)
* `messageDirective` attributes: “attributes are untrusted strings parsed from the directive; the plugin validates its own fields”. The fallback is the source text.
* Panel `params`: “treat `params` as untrusted input (it round-trips through persistence) and re-fetch fresh data by id rather than embedding whole payloads”.
* Images from mentions: “treat remote and page-derived image content as untrusted evidence”.
* Fixed-tab targets: bb checks JSON safety, then calls your own `validate(value): value is Target`.
* Thread metadata and machine/environment inputs: written by anyone, read by every plugin.
* Results must be strict JSON: cycles, bigint, `undefined`, functions, class instances, symbol keys and non-finite numbers are rejected, not coerced.
* `bb.branding.experimental_icons` is run through a reject-only SVG validator, and a violation fails the plugin load, naming the icon.