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; extensionKinds ⊆ eventKinds. |
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: role ∈ provider-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; replaces ≠ id 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: state ∈ running \| 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 --patchResolution (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.