1. The plugin package

A plugin is one npm-shaped package: package.json with a bb block, source entries per tier, and a built dist/ artifact. The loader reads dist/ as data and imports only server.mjs. Where the built loader (packages/kernel-loader) departs from spec 01, the built behavior is the rule (stage-2/04 §1).

1.1 Identity and layout

Term Rule Spec As built
plugin id package.json#bb.id, ^[a-z0-9][a-z0-9-]{0,63}$. Namespaces services (<id>/<name>), event kinds, storage, logs. 01 §1 PLUGIN_ID_RE; reserved: kernel, kernel-ui, kernel-store, kernel-host, kernel-loader, kernel-contract (kernel-core/src/names.ts)
package name package.json#name. Key of the artifact store and of composition name. First-party: @get-bb/plugin-<id>; siblings import only @get-bb/plugin-<id>/contracts. 01 §1 pluginPackageJsonSchema checks name (npm regex) and version (semver); other keys pass
local package @bb-local/<id>: forks and bb plugin new output. npm: refuses the scope. 01 §1 sources.ts refuses @bb-local
trust root system = bundled in the app; user = ~/.bb/plugins/{store,packages,src}. 01 §6 Trust in ports.ts
row One entry of the composition: a plugin id with source, config, disabled, isolation, order. "Row" means composition row throughout this document. 01 §7.2 kernel-loader/src/composition/layers.ts

Source layout (01 §2.1): package.json, src/contracts.ts, src/server.ts, src/app.tsx, src/host.ts, src/cli.ts, src/import.ts, src/reducers.ts, src/app.css, config.schema.json, skills/<name>/SKILL.md, assets/, docs/guide.md, dist/. Every entry is optional; at least one of server, app, host must be set.

1.2 Manifest: package.json#bb

The zod schema is packages/kernel-loader/src/manifest.ts (R35; @bb/plugin-build/manifest re-exports it). The parse is strict: an unknown key fails with its path. Every default is filled once; dist/manifest.json holds the resolved form plus packageName and version.

Key Type Default Meaning Spec As built
id plugin id required Identity. 01 §2.2 Reserved ids fail with plugin id "<id>" is reserved.
name string, 1–120 required Display name. 01 §2.2 same
description string, ≤ 2000 required Store text. 01 §2.2 same
category productivity | providers | ui | integrations | developer | other "other" Store grouping. 01 §2.2 PLUGIN_CATEGORIES
engines { bb: semver range } { bb: "*" } Core version range; unmet → incompatible. hello-slot omits it (default *); the 1.0 value is open (§0.6). 01 §2.7 step 3 readArtifact checks with includePrerelease: true; the core reports 0.0.0 in the tree.
peer { "@bb/ui": semver range } { "@bb/ui": "*" } Served @bb/ui major; excluded → app tier needs-update. hello-slot omits it (default *); the 1.0 value is open (§0.6). 01 §2.2 Checked against meta.uiMajor.
server relative path, 1–512 null Server entry → dist/server.mjs. 01 §2.4 same
app relative path null App entry → dist/app.mjs + app.css. 01 §2.4 same
host relative path null Host entry → dist/host.mjs. 01 §2.4 same
contracts relative path null Contracts module → dist/contracts.mjs + contract.json. Required when provides is non-empty. 01 §2.2 manifestRuleIssues
cli relative path null Offline CLI handlers → dist/cli.mjs. 01 §2.4 path accepted; the entry is never loaded (not built, Appendix B)
provides Record<ServiceId, { version: string; priority: int }> {} Service ids this package implements; priority feeds arbitration. 01 §2.5 priority default 0. Ids not under <id>/ or <replaces>/ are legal only for multi contracts, read from in-process artifacts only (D7).
requires Record<ServiceId, range> {} Hard edge; waiting while unmet. May not overlap uses or name an own provides. 01 §2.5 same
uses Record<ServiceId, range> {} Soft edge; the container re-runs the dependent on rebind. 01 §2.5 same
replaces plugin id null The plugin this package forks. ≠ id; not reserved. 01 §2.5 same
contributes Contributes all defaults §1.3. 01 §2.6 contributesSchema.parse({})

ServiceId is <pluginId>/<serviceName> (isServiceId). A provides entry also needs contracts; the build checks provides against the exported defineService ids (stage-2/04 §3).

1.3 contributes

Key Type Default Meaning Spec As built
config { $ref: path; serverOnly: string[] } or a JSON Schema object null Validates and default-fills row.config. 01 §2.6 serverOnly default []. A $ref file that is not a JSON object → incompatible (resolve.ts). App setup receives {} in the product (D17).
settings Record<key, SettingDescriptor>; key ^[a-zA-Z][a-zA-Z0-9_-]*$ {} Each is the profile preference <id>/<key>; required: true with no value → needs-configuration. Preference key rule (identical in §3.7 and §4.7): the key is the full <pluginId>/<name> string in every tier, with name in ^[a-zA-Z][a-zA-Z0-9_-]*$; each tier that reads the key defines it itself with the same schema and default (a server define does not make the key readable in the browser, nor the reverse); this manifest entry is optional for code — it adds the Settings form row and the required fast path, and nothing checks that its default equals the code default. 01 §2.6, §4.1 Descriptor table below. Read at load only by the required fast path (loader/load.ts missingSetting); preferences.define in neither tier consults it.
commands { nouns: string[] }; noun ^[a-z][a-z0-9-]*$ { nouns: [] } Command-bus nouns this plugin claims. 01 §2.6 Ownership is composition order; reserved nouns kernel, composition, plugin, host, preference, secret (D2).
providers ProviderDeclaration[] [] Provider declarations (06). Keys: id, family, bridge, icon, strings, permissionModes, reasoningLevels, serviceTiers, fork, nativeUserQuestion, manualCompaction, maintenance, visibility, features, composerActions, approvals, extensionKinds, interactionKinds, models, skills, bridgeOptions. 01 §2.6 Strict re-wrap of bridge-kit's schema. bridge must name a provider-bridge host role; extensionKindseventKinds.
eventKinds Record<"<id>/<kind>", KindDeclaration> {} Extension event kinds. 01 §2.6 Key prefix <id>/ or <replaces>/. Row kinds turn, interaction, notice are refused (D11).
interactionKinds Record<"<id>/<kind>", { schema; resolutionSchema }> {} Open request kinds. 01 §2.6 Same prefix rule.
annotations Record<name, JSON Schema>; name ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ {} Thread-annotation keys this plugin writes. 01 §2.6 says <id>/<key> Plain names. A --detach fork renames only prefixed keys (plugin-build README).
legacyImport relative path null dist/import.mjs; run by the importer only. 01 §2.6 same
reducers relative path null dist/reducers.mjs, reducers: Record<coreKind, Reducer>. Honored only for the slot:rows-reducer winner. 01 §2.6 Loaded by the store worker alone (D11); the build refuses non-core kind keys.
hostRoles HostRoleContribution[] [] Roles the host entry exports. 01 §2.6 Shape below; duplicate (role, name) fails.
hostCommands string[]; ^[a-z][a-zA-Z0-9-]*$ [] Names only; schemas live in the contracts module. R9 No dots: inference.complete is inference-complete; the loader regex admits camelCase, defineHostCommands requires kebab-case (§2.6; stage-2/01 §3).
hostSignals string[]; same grammar [] Names the host entry's signals must contain. 01 §2.6 same
cli { commands: string[]; aliases: Record<word, word> }; ^[a-z][a-z0-9-]*$ { commands: [], aliases: {} } Top-level bb <word> names this plugin mounts. 01 §2.6 An alias must point at a listed command. Word → word only; verb aliases are method({ cli: { aliases } }). The list feeds only the plan-time word-collision check between plugins (loader/graph.ts, a degraded problem); the CLI tree mounts from each service's cli.group (kernel-contract:cli/tree.ts), so a group the list omits still mounts and no build or load step cross-checks the two.
database boolean false Permission for storage.database(). Never changes placement (R2). 01 §5.1 same
background string[]; ^[a-z][a-z0-9-]*$ [] Keys of the server entry's background export. Non-empty starts a separate background worker (#10). 01 §5.4 D10; a declared name with no export ends crashed.
slots string[] [] Slot names the app tier claims; informational (R9). 01 §2.6 Cross-checked at build against the collector; rows-reducer exempt (D9).
skills string[] (paths) ["skills"] Skill roots; [] opts out. 01 §2.6 The default root may be absent; a declared one must exist.
themes { id, name (1–64), description, css, codeTheme: { dark, light } }[] [] Theme stylesheets. 01 §2.6 description default null; codeTheme default = design-tokens DEFAULT_CODE_THEME.
tokens Record<name, string>; ^[a-z][a-z0-9-]*$ {} Emitted as --plugin-<id>-<name>. 07 §10.4 Value grammar is linted at build, not load.
icons { plugin: path; logo: { light, dark } | null; named: Record<name, path> } null Plugin icon, logo pair, named icons (<id>/<name>). 01 §2.6 plugin is required once icons is non-null (no default); logo default null, named default {}, named keys ^[a-z][a-z0-9-]*$.
guide relative path null Markdown guide fragment (R5). 01 §2.6 same

SettingDescriptor (settingDescriptorSchema): every type has label (required) and description (default null).

type Extra keys Spec As built
string, text default: string | null, required: boolean (false) 01 §2.6 same
number default, min, max (each null), required 01 §2.6 same
boolean default: boolean (required) no required key required: false accepted, true refused
select options: string[] (≥ 1), default: string | null, required 01 §2.6 default must be one of options
list item: "string", default: string[] | null no required key required: false only
project, host, secret required 01 §2.6 a secret preference holds the reference plugin:<id>/<key>

HostRoleContribution: roleprovider-bridge, environment-provider, skill-discovery, terminal-backend, ai-service, rpc; name ^[a-z0-9][a-z0-9-]*$; launch = { kind: "module", export } or { kind: "exec", command, args }, default { kind: "module", export: <name> }; limits defaults maxRssBytes 1 GiB, idleMs 300000, startTimeoutMs 10000, stopGraceMs 5000, maxLineBytes 1 MiB, maxStderrLines 1000.

KindDeclaration: item: boolean; schema: { open, close, payload } (each JSON Schema or null); deltaPaths: string[] ([]); retention = { kind: "keep" } (default), { kind: "drop-deltas-after-close" }, or { kind: "keep-latest", per: "thread" \| "turn", n }; ignorable (false). An item kind needs open and close; a non-item kind needs payload.

contributes.commands.nouns (at dc07292bf): a noun may be dotted — ^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$ (thread.queue, project.source); nounOf("thread.queue.create") is thread.queue (kernel-loader/src/manifest.ts).

1.4 Cross-field rules and validation order

manifestRuleIssues runs at build (parsePluginPackage) and at load (validateResolvedManifest on dist/manifest.json): at least one of server/app/host; replacesid and not reserved; contracts set when provides is non-empty; no id in both requires and uses; no own provides in requires/uses; eventKinds/interactionKinds keys prefixed by <id>/ or <replaces>/; every providers[].bridge names a provider-bridge role; every extensionKinds entry is in eventKinds; no duplicate host role; every cli.aliases target is in cli.commands.

readArtifact (artifacts/artifact.ts) classifies one package, in this order:

Check Failure status
dist/manifest.json and dist/meta.json exist failed
manifest parses (schema + rules); meta.json parses incompatible
meta.pluginId == bb.id failed
engines.bb satisfied by the core version incompatible
contract.json + contracts.mjs (contracts), app.contributions.json (app), reducers.mjs (reducers), server.mjs (server) exist failed
referenced files (icons, themes, guide, config.$ref, schema $refs, declared skills roots) stay inside the root and exist escape → incompatible; missing → failed
per-file sha256 matches meta.digests failed (verified for npm/url only; README deviation 2)
meta.sdkMajor == 1 when server is set needs-update

Spec 01 §2.7 verifies digests for every source except path:; the built loader skips bundled artifacts too.

1.5 Example: examples/plugins/hello-slot/package.json (trimmed)

{
  "name": "@get-bb/plugin-hello-slot",
  "version": "0.1.0",
  "type": "module",
  "exports": { "./contracts": { "source": "./src/contracts.ts", "types": "./dist/types/contracts.d.ts", "default": "./dist/contracts.mjs" } },
  "files": ["dist", "assets", "src"],
  "scripts": { "build": "bb plugin build . && tsc -p tsconfig.types.json" },
  "bb": {
    "id": "hello-slot",
    "name": "Hello Slot",
    "description": "Stage 1 smoke: greet over HTTP/SDK/CLI/tool, count in data.db, a root panel, a scratch environment provider.",
    "category": "developer",
    "server": "./src/server.ts", "app": "./src/app.tsx", "host": "./src/host.ts", "contracts": "./src/contracts.ts",
    "provides": { "hello-slot/greeter": { "version": "1.0.0" } },
    "contributes": {
      "database": true,
      "cli": { "commands": ["hello"] },
      "slots": ["root"],
      "hostRoles": [{ "role": "rpc", "name": "echo" }, { "role": "environment-provider", "name": "scratch" }],
      "hostCommands": ["host-greet"],
      "icons": { "plugin": "./assets/icon.svg", "named": { "wave": "./assets/wave.svg" } }
    }
  },
  "dependencies": { "@bb/ui": "workspace:*", "@get-bb/plugin-sdk": "workspace:*", "zod": "4.3.6" },
  "peerDependencies": { "react": "^19.0.0" }
}

A plugin imports @get-bb/plugin-sdk, @bb/ui, zod, and React types, nothing else (stage-2/04 intro). tsconfig.json sets customConditions: [] (D20). A minimal server-only manifest is the SDK fixture (packages/plugin-sdk/src/fixture/manifest.ts): id, provides, uses: { "threads/agent-config": "^1.0.0" }, contributes: { database, background: ["tick"], settings, hostRoles, hostCommands, hostSignals, slots }.

Dependencies as built: @get-bb/plugin-sdk is 1.0.0-next.0 and @bb/ui 0.0.0 (packages/plugin-sdk/package.json, packages/ui/package.json), both workspace:* in the tree; the published ranges are open (§0.6). The SDK and the example pin zod to 4.3.6. The collector needs zod 4 (the collector calls z.toJSONSchema, kernel-ui:boot/contributions.ts; inputs are z.strictObject) and each tier bundles the plugin's own copy (D18). React is a peer (^19.0.0; types @types/react ^19.2.0); happy-dom (^20.11.0) is a dev dependency for the app test (§9.3).

Starter configs (hello-slot's files; its tsconfig.json extends the monorepo tsconfig.base.json, inlined here):

// tsconfig.json
{ "compilerOptions": { "strict": true, "target": "ES2023", "lib": ["ES2023", "DOM", "DOM.Iterable"],
    "module": "NodeNext", "moduleResolution": "NodeNext", "jsx": "react-jsx", "types": ["node"],
    "isolatedModules": true, "verbatimModuleSyntax": true, "skipLibCheck": true, "customConditions": [],
    "noEmit": true, "rootDir": "src", "outDir": "dist/types" }, "include": ["src"] }
// tsconfig.types.json: extends ./tsconfig.json; noEmit false, declaration + emitDeclarationOnly true; include ["src/contracts.ts"]
// vitest.config.ts: defineConfig({ test: { include: ["src/**/*.test.ts"], testTimeout: 30_000, hookTimeout: 60_000 } })
//   — no `environment`: every file runs in node and app.test.ts installs happy-dom itself (§9.3)

1.6 Tiers and entry files

Tier Export Runs in Spec As built
server definePlugin({ config?, activate(ctx, config), background? }) core, in-process or worker_threads 01 §2.4 ctx is PluginContext (Scope + storage, log, secrets, hostClient); id comes from the manifest (D19)
app definePluginApp({ setup(app, config) }) browser 01 §2.4 write setup synchronous: the type admits a Promise and the browser and the build await one, but createAppHarness refuses it (§4.1); config is {} in the product (define-plugin-app.ts, config ?? {})
host defineHostEntry({ roles, signals, dispose }) host role process 05 §4.7 commands is derived from the rpc roles (D14)
contracts defineService / defineEvent / defineHostCommands only everywhere as data 02 §9 the one module siblings import
cli offline handlers for cli.offline methods the bb binary 03 §5.9 not built: the build emits dist/cli.mjs; the bb binary does not load it (Appendix B)
import LegacyImportModule the importer 04 §9.4 from contributes.legacyImport
reducers reducers: Record<coreKind, Reducer> the store worker 04 §6.3 from contributes.reducers

TIERS in packages/plugin-build/src/externals.ts lists the seven; TIER_OUTPUT maps each to its .mjs.

1.7 Build outputs: dist/

bb plugin build [dir] runs one esbuild pass per tier, a Tailwind pass for app, writes dist/ atomically (stage dir → swap), and prints the digest. bb plugin dev [dir] installs the dir as path: on the first successful build, watches (300 ms debounce), rebuilds unminified with sourcemaps, and reloads. Both are @bb/plugin-build functions mounted by the plugin-dev plugin.

File Written when Read by
manifest.json always loader (validateResolvedManifest re-runs the same rules)
meta.json always, last loader: store key, host artifact lease key, ?v= on app asset URLs
contract.json contracts set core (JSON Schema validation for worker rows), the catalog, docs
app.contributions.json app set kernel-ui at load; a registration the file lacks fails with contributions_mismatch (D9)
server.mjs server set core import() (in-process) or the worker bootstrap
app.mjs, app.css app set the browser through the import map
host.mjs host set kernel-host's role bootstrap
contracts.mjs contracts set sibling plugins, the browser (import map), the SDK generator
cli.mjs / import.mjs / reducers.mjs entry set bb binary (not built: never loaded, Appendix B) / importer / store worker
*.mjs.map sourcemap builds tooling; never an app.css.map
assets (assets/**, skills/**, docs/guide.md, theme css, config.schema.json, schemas/*.json) referenced core (/plugins/<id>/…), the guide renderer

meta.json = { artifactFormatVersion: 2, sdkMajor: 1, uiMajor: 1, sdkVersion, pluginId, pluginVersion, bbVersion, builtAt, digests }. The artifact digest is sha256(sorted "path\0digest\n"), printed sha256:<hex>; builtAt never moves it. app.css utilities carry the two-arm :where([data-bb-plugin="<id>"]) prefix; authored CSS must root every selector at [data-bb-plugin-effect="<id>"] (R34; 01 §3.3's "stays unscoped" is superseded). Build failures, not load failures: contributes.slots ≠ collector names; provides ≠ exported ids; authored CSS outside the effect root; /app or /testing imported from a Node tier; a sibling subpath other than /contracts; a color-literal token. contract.json and app.contributions.json are hook-driven (hooks: { contract, appContributions }); plugin-dev wires the real hooks. bb plugin pack [dir] writes a deterministic npm-shaped tarball (honors files / .npmignore / .gitignore; never ships .env*, .npmrc; refuses an unbuilt package). bb sdk types is a bundled offline handler (packages/cli/src/_pending/plugin-dev-cli.ts). bb plugin new and bb plugin types are spec verbs (01 §3.1) that @bb/plugin-build does not ship; hello-slot uses tsc -p tsconfig.types.json as the stand-in.

1.8 Install sources and the artifact store

Spec Fetch Trust Isolation Spec As built
npm:<name>[@range|tag|version] packument → tarball; no dependency install; refuses @bb-local user worker 01 §3.6 sources.ts; streaming SRI check; ≤ 64 MiB
url:<https-url> fetch tarball (https: only), unpack, digest; confirm.digest pins it user worker R1, 01 §3.6 same path as npm: after fetch; refuses a tarball without dist/
path:<dir> none; loads <dir>/dist in place; mutable user inproc 01 §3.6 digests not verified; stale tier possible
bundled:<id> none; <app>/plugins/<id>/dist system inproc 01 §3.6 sugar for setRow { id, disabled: false }

Install = resolve → fetch → verify → Storage.unpack into plugins/store/<digest> → write versions/<v> → flip current → composition effect → reload. previous keeps the old pointer for rollback. Keep 3 versions per package (KEEP_VERSIONS). GC removes unreferenced store/ dirs after 7 days and orphaned .tmp-* stages after 1 hour. gc runs at boot and after update, rollback, and remove. Pointer files carry three lines <version>\n<digest>\n<resolution json>\n (README deviation 16; spec shows two). A system record is never replaced by an npm:/url: install of the same name (README deviation 14). Moved-tag refusal: an update whose same version resolves to a different integrity is refused unless force.

Composition effect (01 §3.5): install with no row naming the package → append a user-layer insert { id, name }; a row already names it → no tree change, reload that row. remove of a user-owned insert → delete the insert; of a base row → { id, disabled: true }; --purge refuses while the row is enabled.

1.9 Status

PluginStatus (status.ts, canonical per R13), thirteen values:

Value Meaning
missing The row names a package that is not installed.
incompatible Manifest invalid, engines.bb unmet, or a reference escapes the root.
needs-update sdkMajor/uiMajor mismatch; other tiers may still load.
disabled row.disabled is true.
replaced Another enabled, healthy row's manifest replaces this plugin (by <forkId>).
waiting A requires id is unbound, or the row is in a requires cycle.
activating activate() runs, within activationTimeoutMs.
running Healthy; registrations published.
degraded Serving with recorded problems (failed reload with the old generation live; registration conflicts; contested noun, CLI word, or tool name; config problems; foreign provides) (D1, D8).
needs-configuration A required setting has no value (fast path, before activate) or ctx.status.needsConfiguration().
failed activate threw or timed out, a digest or file check failed, or the worker crashed.
disposing container.remove() in progress.
disposed Unloaded; the row stays in the tree until the next resolve.

TierState, per server/app/host:

Value Meaning
absent The entry is not set.
ready The entry is set and its major matches.
needs-update sdkMajor ≠ 1 (server, host) or uiMajor outside peer["@bb/ui"] / ≠ 1 (app).
error Reserved in the union; readArtifact does not produce it.
stale A path: install whose source (outside dist/, node_modules/, dot-dirs) is newer than meta.builtAt; computed on demand by bb plugin show (load.ts tiersWithStale).

PluginRuntimeStatus (wire shape pluginStatusSchema): rowId, pluginId, packageName, version, digest, status, detail, generation, activation, isolation, trust, optional, tiers, stats, services, updatedAt. services holds one BackgroundServiceState per background service: staterunning \| backoff \| stopped \| crashed \| needs-configuration, restarts, lastError, startedAt, nextRestartAt. detail for waiting reads needs <id> <range> (have <version|none>). The needs-configuration fast path re-runs only on a kernel/preferences.changed for one of the plugin's contributes.settings keys (D8).

1.10 Placement

isolation = row.isolation            (composition override)
       else "worker"                 (source npm or url)
       else "inproc"                 (bundled and path:, always; R2)

contributes.database never changes placement (R2). A non-empty contributes.background starts a separate background worker that runs only the background export; the server entry stays where the rule puts it (D10). A row.isolation: inproc restate on a marketplace row is refused: the row stays worker with a degraded problem (resolve.ts). Every Stage 2 plugin is in-process (D7). Worker gaps: declared input .default()/.transform() do not reach a worker handler; kind: custom methods in a worker's contract.json are refused; per-worker limits are 256 active calls, 32 MiB in flight, 8 MiB per frame, 512 MB old-generation heap. Background services: start after running/degraded/needs-configuration; resolve → stopped; throw → crashed with backoff min(1000 ms × 2^n, 60 s); no kernel schedules table — loop while (!signal.aborted) { …; await sleep(ms, signal) } (D10).

1.11 Composition

Three layer kinds, applied in order over an empty list (01 §7.1, R29):

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 humans, agents (CLI), Settings via kernel/composition, install/remove/fork
3..n patch bb-server --patch <file> (repeatable) developers

File format (composition/layers.ts layerFileSchema): version: 1 and rows:. Row fields are id (plugin id), name, config, disabled, inject (service ids), isolation (inproc \| worker), source (bundled \| path), optional, experimental: { summary }. Two op kinds: insert ({ insert: { after?, before?, rows } }) and patch (a plain row in a non-base layer). Rules: base-layer plain rows are one insert and each needs a name; 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; every stated field replaces the field whole — no deep merge; there is no remove op. $env is the one expression: { $env: NAME, default: JsonValue }, resolved before config validation.

# ~/.bb/composition.yaml
version: 1
rows:
  - id: github
    disabled: false                        # enable an optional bundled row
  - id: ui-markdown
    config: { mermaid: false, katex: true }   # whole config restated
  - insert:
      after: ui-diff
      rows:
        - id: hello-slot
          name: "@get-bb/plugin-hello-slot"   # examples/hello-slot.composition.yaml uses the same insert as a --patch

Resolution (resolve.ts): name → install record, preferring the path: install when source: path or no bundled artifact exists; read the artifact; set isolation by source; resolve $env; validate and default-fill config against contributes.config through the JSON Schema port. A row whose bb.id differs from the installed id is incompatible. Tree checks: one enabled row per package, CLI-word and tool-name collisions, noun claims, requires cycles, replaces chains. Arbitration for duplicate service ids and exclusive slots is replaces → pin → priority → sorted id (container.order()); bb composition dump previews it. The composition file is not watched in the product (D8; the store-worker Storage proxy lacks watch, README deviation 19): an external edit needs kernel/plugins.reload or a restart. A layer that fails to parse is rejected whole; the previous tree stays booted and dump prints composition error (previous tree still booted).

1.12 Fork

bb plugin fork <id> [--as <newId>] [--detach] = kernel/plugins.fork { id, as: null, detach: false }. No build step. newId defaults to <id>-fork; it must match the id pattern, not be reserved, not equal the original, not be installed or named by a row, and ~/.bb/plugins/src/<newId> must not exist. The original must ship source (src/ or any declared entry file); a dist/-only package is refused with no source shipped for <id>.

Step Default (replaces) --detach
copy source to ~/.bb/plugins/src/<newId>/ (no node_modules, .git, dist) yes yes
copy the built dist/ yes only when the original has no contracts module; otherwise every tier starts absent until bb plugin dev
rewrite package.json: name@bb-local/<newId>, bb.id, bb.name"<Name> (fork)" bb.replaces: <id> replaces deleted; provides, eventKinds, interactionKinds, annotations keys and providers[].extensionKinds/interactionKinds renamed <id>/<newId>/
rewrite dist/manifest.json (+ packageName) and dist/meta.json (pluginId, manifest.json digest) when dist/ is copied when dist/ is copied
write FORKED_FROM.json { pluginId, packageName, version, digest | null, forkedAt } yes yes
install as path:~/.bb/plugins/src/<newId> (trust user, inproc), record forkedFrom yes yes
user layer: insert { id: newId, name: "@bb-local/<newId>" } after the original yes yes, plus { id: <original>, disabled: true } in the same write (README deviation 1)

Spec 01 §8.1 keeps dist/ for a detached fork; the built forkPluginSource (plugin-build/src/fork.ts) does not, because contracts.mjs declares the old ids. The rewrite is validated through the manifest schema before any byte is copied; a failing rewrite is a ForkError that leaves no directory. With replaces, the fork inherits the original's storage namespace (kv, data.db, preferences, logs), noun claims, CLI words, arbitration rank, and dependents' requires edges (01 §8.2); Loader.storageIdOf walks replaces to the chain root. Output: newId, sourceDir, and next: edit <dir>, then bb plugin dev <dir>. bb plugin revert <id> (fork or original) disables the fork row and re-enables an original the detach disabled. bb plugin remove <forkId> deletes the insert, the install row, and the plugins/src dir; --purge on a replaces fork refuses with storage belongs to <original>; purge that plugin instead.

1.13 Per-plugin storage

Under ~/.bb/ (data dir, Storage port; the loader never imports node:fs):

Path Holds Spec As built
composition.yaml the user layer 01 §7.1 USER_LAYER_FILE
plugins/store/<digest>/ unpacked immutable artifact 01 §3.5 artifacts/store.ts
plugins/packages/<name>/{versions/<v>, current, previous} pointer text files 01 §3.5 three-line pointers
plugins/src/<pluginId>/ user-trust source (forks, bb plugin new), installed as path: 01 §3.5 removed with the install row
plugins/data/<storageId>/data.db per-plugin SQLite; storageId = replaces-chain root 01 §9 load.ts
plugins/data/<storageId>/logs/<yyyymmdd>[.<n>].jsonl JSONL logs, 8 MiB × 5 rotation 01 §9 says log/ storage/logs.ts says logs/
plugins/host/<pluginId>/ host-tier data on the machine that runs the role 01 §3.5, 05 §2.12 same
store.db table plugin_kv ctx.storage.kv values 01 §9 value ≤ 256 KiB, key ≤ 256 bytes

Server-tier API (PluginContext, ports.ts): ctx.storage.kv.get/set/delete/list(prefix); ctx.storage.database() → async SqliteHandle (exec, run, get, all, batch, close), requires contributes.database: true else precondition; ctx.storage.migrate(db, statements) (append-only, keyed by index, CREATE TABLE not IF NOT EXISTS) — the SDK wraps both as ctx.storage.openDatabase(migrations) (D13); ctx.log.debug/info/warn/error(message, fields?); ctx.secrets.resolve({ name }); ctx.hostClient(contract, name) for a declared hostRoles[].name. Settings are preferences: read kernel/preferences.get { key: "<id>/<key>", scope: "profile", threadId: null }, write with expectedUpdatedAt: number | null (#29). A secret setting holds plugin:<id>/<key>; bb secret put plugin:<id>/<key> writes the value. An in-process row's data.db writer is the store worker behind the sqlite envelope; a worker row holds a local driver (R2). The handle is per generation; a call after dispose rejects stale_handle. bb plugin remove <id> keeps storage; --purge deletes kv, preferences, data.db, logs.

1.14 The bb plugin and bb composition CLI

Both trees derive from kernel/plugins (service.ts, group plugin, extensible) and kernel/composition (composition/service.ts, group composition). Mutations take actors: ["human", "agent"]; a plugin actor gets forbidden. The plugin id is positional 0 on id-taking words.

Word Method Input (as built) Notes
bb plugin list list {} { plugins: PluginStatusJson[] }; no update columns
bb plugin show <id> show { id } status with stale tiers, manifest, contract, appContributions, sourceDir, forkedFrom
bb plugin install <source> [--force] install { source, confirm: null, force: false } npm:/url:/path:/bundled:; confirm = { name, version, digest } is the store's pin. service.ts declares source positional 0; D9 records the word as unparsed at Stage 2 entry and names POST /api/v1/kernel/plugins/install or bb plugin dev <dir>
bb plugin plan <source> plan { source } resolution without unpack (spec spells it install --plan)
bb plugin update [--force] update { id: string | null, all, check, force }, exactly one of id/all check: true resolves only; --check is the plugin-store alias (R14)
bb plugin rollback <id> rollback { id } pointer swap
bb plugin remove <id> [--purge] remove { id, purge } destructive; base rows are disabled
bb plugin reload <id> reload { id } new generation
bb plugin fork <id> [--as] [--detach] fork { id, as: null, detach: false } §1.12; offline fallback in the bb binary
bb plugin revert <id> revert { id } { forkId, originalId }
bb plugin enable|disable <id> enable / disable { id } sugar for setRow { id, disabled }
bb plugin dev --stop <id> devStop { id } drops the source: path restate, re-records bundled:
bb plugin logs <id> [--follow] [--since] logs (stream) { id, follow, since: number | null } polls day files every 1 s by byte offset
bb plugin build|dev|pack, bb sdk types plugin-dev plugin (bundled offline handlers today) §1.7; bb plugin new and bb plugin types are spec only
bb plugin update --check [id] plugin-store/store.updates { id, refresh: true } absent when plugin-store is disabled
Word Method Input (as built) Notes
bb composition dump [--row <id>] dump { row: null } cli.offline; text or --json (dumpSchema: layers, rows with origin/status/generation, arbitration, error)
bb composition layer user layer { layer: "user" } { text, revision }
bb composition patch patch { ops: yaml, revision } validated against the full stack; conflict on a stale revision
spec: bb composition set <id> … setRow { id, disabled, name, config, isolation, source }, each null = leave alone one restate; word derived from setRow
spec: bb composition insert … insertRow { row: { id, name, config }, after } one insert
bb composition pin <target> <pluginId> pin { target, pluginId: string | null } targets service:<id>, slot:<name>[:<key>], pane:<kind>, tab:<kind>; null clears
bb composition pins pins {} each entry with live
bb composition experiments experiments {} base rows with experimental and enabled

Reserved names (stage-2/04 §4): kernel CLI groups auth, catalog, composition, guide, host (machine), import, plugin, preferences, realtime, runtime, secret, store, system, ui; extensible groups plugin, secret, host, runtime; a duplicate top-level word is dropped with a warning: line, never a failure (D6). Tool names default to <plugin_id>_<service>_<method> (snake_case, ≤ 64 chars); a collision is a plan-time degraded.