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.body on 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.yaml with 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-list under ~/.bb/plugins/src/ that replaces ui-thread-list (the intended workflow), and at dc07292bf a runnable fork my-env-local of env-local.
  • An npm package @you/bb-plugin-counter installed from npm:, 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 counter

Three 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):

  1. Copies the source to ~/.bb/plugins/src/<newId>/ without node_modules, .git, dist, then copies the built dist/.
  2. Rewrites package.json: name@bb-local/<newId>, bb.id<newId>, bb.name"<Name> (fork)" ("Local environments (fork)"), bb.replaces → the original id. Rewrites dist/manifest.json and dist/meta.json to match.
  3. Writes FORKED_FROM.json { pluginId, packageName, version, digest, forkedAt }.
  4. Installs the directory as path: (trust user, in-process) and records forkedFrom.
  5. 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-local

remove --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 ./counter

Install 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, logs

An 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 wrote

Naming: @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 new and bb plugin types are spec verbs that @bb/plugin-build does not ship; start from hello-slot's files (§1.7).
  • bb composition set and bb composition insert are spec spellings; the built words derive from setRow and insertRow (§1.14). bb plugin enable|disable cover the common case.
  • A row.isolation: inproc restate on a marketplace row is refused; the row stays worker with a degraded problem (§1.10). contributes.database never changes placement (R2).
  • Worker placement has gaps: declared input .default()/.transform() do not reach the handler; kind: custom methods are refused in a worker's contract.json (D7). Test both placements before you publish to npm:.
  • At dc07292bf, base.yaml boots only the domain and provider rows; the ui-* rows are present only as comments, so bb plugin enable ui-thread-list answers row ui-thread-list not found; use insert. insert the row (user layer or --patch) before bb plugin enable or bb plugin fork, and expect missing until its package is installed.
  • A path: install's tiers read stale in bb plugin show when source is newer than meta.builtAt; rebuild.
  • fork --detach disables the original in the same write (README deviation 1); a default fork leaves it installed, replaced, for revert.
  • kernel/plugins.install had no CLI positional at Stage 2 entry (D9); at dc07292bf source is positional 0, and the HTTP form is POST /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/composition methods), §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, and next/plugins/env-local/package.json (files ships src).
  • Guide page 11 (env-local's roles) and page 13 for proving a fork's contracts before bb plugin dev.