Composition, forks, and distribution
This page shows how bb decides which plugins run, how you change that decision without touching bb's source, and how you ship a plugin to other machines. You read the booted tree with bb composition dump, edit the user layer, pin a winner, fork a plugin to change what it does (the intended target is ui-thread-list; the fork you can run at dc07292bf is env-local), and publish a package whose updates roll back.
Use this when
- Customize a first-party screen. The thread list, the timeline, or the composer, without maintaining a fork of bb.
- Make my thread list the default. You wrote your own and want it to win
sidebar.bodyon this machine. - Ship an update with a rollback. One command updates an installed plugin; one command puts the old version back.
- Flip an experiment or a bundled row. Enable an experiment that ships disabled, or disable a bundled plugin.
- Boot a development plugin from a patch file.
bb-server --patch, as the smoke does.
What you build
- A user layer at
~/.bb/composition.yamlwith an insert, a disable, and a restated config. - A pin on
slot:sidebar.body, and the service call that clears it. - A fork
my-thread-listunder~/.bb/plugins/src/that replacesui-thread-list(the intended workflow), and atdc07292bfa runnable forkmy-env-localofenv-local. - An npm package
@you/bb-plugin-counterinstalled fromnpm:, updated, and rolled back.
Steps
1. Read the tree bb booted
bb composition dump # text: layers, rows with origin/status/generation, arbitration
bb composition dump --json # { version: 1, layers, rows, arbitration, error }
bb composition dump --row counterThree layer kinds apply in order over an empty list (§1.11):
| Order | Layer | Path | Written by |
|---|---|---|---|
| 1 | base | apps/bb-app/composition/<name>.yaml; bb-server --composition <name>, default base |
the release |
| 2 | user | ~/.bb/composition.yaml |
you, agents through the CLI, Settings through kernel/composition, install/remove/fork |
| 3..n | patch | bb-server --patch <file> (repeatable) |
developers |
A row is { id, name, config?, disabled?, inject?, isolation?, source?, optional?, experimental? }. The base layer uses plain rows; base.yaml at dc07292bf boots the domain rows (threads, projects, environments, env-local) and the provider rows (providers, provider-claude-code). The ui-*, experiment, and feature rows below them are comments, not rows.
2. Write the user layer
Two op kinds exist: insert ({ insert: { after?, before?, rows } }) and a patch, which is a plain row naming an existing id. Every stated field replaces the field whole; there is no deep merge and no remove op.
# ~/.bb/composition.yaml
version: 1
rows:
- id: github
disabled: false # enable an optional bundled row
- id: ui-markdown
config: { mermaid: false, katex: true } # the whole config, restated
- id: counter
isolation: worker # inproc | worker; a marketplace row cannot be restated to inproc
source: path # bundled | path: prefer the path: install over the bundled artifact
- insert:
after: ui-diff
rows:
- id: hello-slot
name: "@get-bb/plugin-hello-slot"Rules the loader enforces: an insert needs a name and refuses an existing id (row X already exists; use a patch); a patch on an unknown id fails (row X not found; use insert); optional and experimental are base-layer fields. { $env: NAME, default: <json> } is the one expression, resolved before config validation. A layer that fails to parse is rejected whole; the previous tree stays booted and dump prints composition error (previous tree still booted).
The file is not watched (D8). After an edit run bb plugin reload <id> or restart the server. The same insert as a developer overlay is bb-server --patch examples/hello-slot.composition.yaml; the row is missing until bb plugin install path:<dir> and running afterwards, and because the row already names the package the install writes no second insert.
3. Pin a winner
Exclusive slots, pane kinds, tab kinds, and single-provider services arbitrate by replaces → user pin → priority → sorted plugin id. A pin is one entry in the profile preference kernel/pins, keyed by the target string. kernel/composition.pin takes { target, pluginId: string | null } and declares no cli.fields, so the built word takes --target and --plugin-id flags (§8.4: a field is positional only with positional: n); §1.14 spells the spec's bb composition pin <target> <pluginId>.
bb composition pin --target slot:sidebar.body --plugin-id my-thread-list # my thread list is the default
bb composition pin --target slot:timeline.row:task --plugin-id workflows # one key of a keyed slot
bb composition pin --target pane:thread --plugin-id my-thread-page
bb composition pin --target service:ui-markdown/render --plugin-id my-markdown
bb composition pins # each entry with `live`Clearing is pluginId: null on the same method. The CLI derives no spelling for a null field at dc07292bf (pluginId is a required string flag; §8.4), so clear a pin over HTTP or the SDK:
curl -X POST "$BB_SERVER_URL/api/v1/kernel/composition/pin" -H 'content-type: application/json' \
-d '{"target":"slot:sidebar.body","pluginId":null}' # → { pins }; or: sdk.call("kernel/composition", "pin", { target, pluginId: null })Targets are service:<id>, slot:<name>[:<key>], pane:<kind>, tab:<kind>. A pinned plugin that is absent or crash-latched is skipped, never an error, and a kernel/preferences.changed for the map re-arbitrates every outlet (§4.4, §5.19).
4. Fork a plugin to change what it renders
The intended target is ui-thread-list: it occupies sidebar.body and declares the row slots threadList.row.leading, threadList.row.trailing, threadList.row.status, threadList.row.menu, plus threadList.section* and threadList.groupBy (§5.3). If a list slot gives you what you need, register into it; a fork is for changing the row itself. At dc07292bf the ui-* rows in base.yaml are comments and no @get-bb/plugin-ui-thread-list package exists under plugins/, so forkPlugin finds no enabled row naming the id and answers not_found: no installed plugin ui-thread-list (kernel-loader/src/fork.ts). The workflow, once the row is inserted and enabled:
bb plugin fork ui-thread-list --as my-thread-list
# → newId: my-thread-list, sourceDir: ~/.bb/plugins/src/my-thread-list
# next: edit <dir>, then bb plugin dev <dir>
cd ~/.bb/plugins/src/my-thread-list
$EDITOR src/app.tsx # the ThreadList occupant of sidebar.body
bb plugin dev . # installs the dir on the first successful build, watches, rebuilds with sourcemaps, reloads
bb plugin show ui-thread-list # status: replaced (by my-thread-list)To run the same mechanics today, fork a row base.yaml enables whose package ships src in files: env-local, the local environment providers (page 11).
bb plugin fork env-local --as my-env-local
# → newId: my-env-local, sourceDir: ~/.bb/plugins/src/my-env-local
cat ~/.bb/plugins/src/my-env-local/FORKED_FROM.json # { pluginId: "env-local", packageName: "@get-bb/plugin-env-local", version, digest, forkedAt }
bb composition dump --row my-env-local # the user-layer insert after env-local
bb plugin show env-local # status: replaced (by my-env-local)
cd ~/.bb/plugins/src/my-env-local && $EDITOR src/host.ts && bb plugin dev .What fork does, with no build step (§1.12):
- Copies the source to
~/.bb/plugins/src/<newId>/withoutnode_modules,.git,dist, then copies the builtdist/. - Rewrites
package.json:name→@bb-local/<newId>,bb.id→<newId>,bb.name→"<Name> (fork)"("Local environments (fork)"),bb.replaces→ the original id. Rewritesdist/manifest.jsonanddist/meta.jsonto match. - Writes
FORKED_FROM.json{ pluginId, packageName, version, digest, forkedAt }. - Installs the directory as
path:(trustuser, in-process) and recordsforkedFrom. - Inserts
{ id: <newId>, name: "@bb-local/<newId>" }into the user layer after the original.
Because the fork carries replaces, it inherits the original's storage namespace (kv, data.db, preferences, logs: Loader.storageIdOf walks replaces to the chain root), noun claims, CLI words, arbitration rank, and its dependents' requires edges. The original's routes are removed by replaces; a fork needs no route priority. Nothing else changes: my-env-local's provides still says env-local/..., environments keeps its requires edge, and siblings keep importing @get-bb/plugin-env-local/contracts.
--detach is the other mode: replaces is deleted; provides, eventKinds, interactionKinds, annotations, and providers[].extensionKinds/interactionKinds keys are renamed <id>/ → <newId>/; dist/ is copied only when the original has no contracts module (its contracts.mjs declares the old ids), so every tier starts absent until bb plugin dev; and the user layer gets { id: <id>, disabled: true } in the same write (D10, README deviation 1).
Undo and clean-up:
bb plugin revert my-env-local # disables the fork row; re-enables an original a detach disabled
bb plugin remove my-env-local # deletes the insert, the install row, and plugins/src/my-env-localremove --purge on a replaces fork refuses: storage belongs to env-local; purge that plugin instead.
5. Install from a source
| Source | Fetch | Trust | Isolation | Notes |
|---|---|---|---|---|
npm:<name>[@range|tag|version] |
packument → tarball; no dependency install; refuses @bb-local |
user | worker | streaming SRI check; ≤ 64 MiB |
url:<https-url> |
fetch tarball, unpack, digest; confirm.digest pins it |
user | worker | refuses a tarball without dist/ |
path:<dir> |
none; loads <dir>/dist in place; mutable |
user | inproc | digests not verified; tiers can read stale |
bundled:<id> |
none; <app>/plugins/<id>/dist |
system | inproc | sugar for setRow { id, disabled: false } |
bb plugin plan npm:@you/bb-plugin-counter # resolution without unpack
bb plugin install npm:@you/bb-plugin-counter # → PluginStatusJson; composition effect: a user-layer insert
bb plugin install url:https://example.com/counter-0.2.0.tgz
bb plugin install path:./counter # or: bb plugin dev ./counterInstall is resolve → fetch → verify → unpack into plugins/store/<digest> → write plugins/packages/<name>/versions/<v> → flip current → composition effect → reload. The store is immutable and digest-addressed; previous keeps the old pointer.
6. Update and roll back
bb plugin update counter # exactly one of an id or --all
bb plugin update --all --check # resolve only; the plugin-store alias is --check
bb plugin rollback counter # pointer swap: current ← previous
bb plugin remove counter # keeps storage; --purge deletes kv, preferences, data.db, logsAn update is a pointer flip. Three versions per package are kept (KEEP_VERSIONS); GC removes unreferenced store/ dirs after 7 days and runs at boot and after update, rollback, and remove. An update whose same version resolves to a different integrity is refused unless --force (moved tag). A system record is never replaced by an npm:/url: install of the same name. update and the other mutations take actors: ["human", "agent"]: a plugin calling them gets forbidden.
7. Package for npm
{
"name": "@you/bb-plugin-counter", // any npm name; first-party is @get-bb/plugin-<id>
"version": "0.2.0",
"type": "module",
"files": ["dist", "src"], // src ships so `bb plugin fork` can copy it
"exports": { "./contracts": { "source": "./src/contracts.ts", "default": "./dist/contracts.mjs" } },
"bb": { "id": "counter", "engines": { "bb": "*" }, "peer": { "@bb/ui": "*" }, /* … */ },
"scripts": { "build": "bb plugin build ." }
}bb plugin build . # dist/: manifest.json, meta.json, contract.json, app.contributions.json, *.mjs; prints sha256:<hex>
bb plugin pack . # deterministic npm-shaped tarball; honors files/.npmignore/.gitignore; never ships .env*, .npmrc
npm publish # the tarball bb plugin pack wroteNaming: @get-bb/plugin-<id> is the first-party convention and what contractsSpecifier(id) resolves for siblings; @bb-local/<id> is reserved for forks and local source, and npm: refuses the scope. A dist/-only package cannot be forked (no source shipped for <id>), so keep src in files. bb.engines.bb and bb.peer["@bb/ui"] default to *; the 1.0 ranges to pin are open (§0.6). npm: installs no dependencies: the build bundles zod into every tier, and anything else you import must be bundled too.
8. Ship an experiment
A base row flagged experimental ships disabled and shows up in bb composition experiments and Settings › Experiments (spec).
# base layer
- id: timeline-edit-messages
name: "@get-bb/plugin-timeline-edit-messages"
disabled: true
experimental: { summary: "Edit a sent message and fork the thread from that point" }bb composition experiments # { experiments: [{ id, summary, enabled }] }
bb plugin enable timeline-edit-messages # sugar for setRow { id, disabled: false }What happens at runtime
Resolution (resolve.ts, §1.11): name → install record, preferring the path: install when source: path or no bundled artifact exists → readArtifact (manifest, meta.json, engines.bb, referenced files, digests for npm/url, sdkMajor) → isolation by source (row.isolation override, else worker for npm/url, else inproc) → $env → config validated and default-filled against contributes.config → tree checks (one enabled row per package, CLI word and tool name collisions, noun claims, requires cycles, replaces chains) → arbitration → boot in topological order, sequentially. Collisions and config problems are degraded with detail, not failed loads (D8). bb composition dump previews the arbitration the container will apply.
Pitfalls
bb plugin newandbb plugin typesare spec verbs that@bb/plugin-builddoes not ship; start from hello-slot's files (§1.7).bb composition setandbb composition insertare spec spellings; the built words derive fromsetRowandinsertRow(§1.14).bb plugin enable|disablecover the common case.- A
row.isolation: inprocrestate on a marketplace row is refused; the row staysworkerwith adegradedproblem (§1.10).contributes.databasenever changes placement (R2). - Worker placement has gaps: declared input
.default()/.transform()do not reach the handler;kind: custommethods are refused in a worker'scontract.json(D7). Test both placements before you publish tonpm:. - At
dc07292bf,base.yamlboots only the domain and provider rows; theui-*rows are present only as comments, sobb plugin enable ui-thread-listanswersrow ui-thread-list not found; use insert.insertthe row (user layer or--patch) beforebb plugin enableorbb plugin fork, and expectmissinguntil its package is installed. - A
path:install's tiers readstaleinbb plugin showwhen source is newer thanmeta.builtAt; rebuild. fork --detachdisables the original in the same write (README deviation 1); a default fork leaves it installed,replaced, forrevert.kernel/plugins.installhad no CLI positional at Stage 2 entry (D9); atdc07292bfsourceis positional 0, and the HTTP form isPOST /api/v1/kernel/plugins/install {"source": …, "confirm": null, "force": false}.- Pointer files are three lines (
version,digest, resolution JSON), not the spec's two (README deviation 16). Do not edit them by hand.
See also
- Reference §1.8 (sources and the store), §1.11 (layers and the file format), §1.12 (fork), §1.14 (the two CLI trees), §3.11 (
kernel/compositionmethods), §4.4 and §5.19 (arbitration and pin targets), §5.3 (the thread-list slots), §8.4 (how flags derive). next/apps/bb-app/composition/base.yaml,next/examples/hello-slot.composition.yaml, andnext/plugins/env-local/package.json(filesshipssrc).- Guide page 11 (
env-local's roles) and page 13 for proving a fork's contracts beforebb plugin dev.