3. Server tier — @get-bb/plugin-sdk/server
The server tier is what dist/server.mjs exports: one definePlugin product. The loader calls activate(facade, config); the SDK wraps the loader facade in a PluginContext that is 02's Scope plus the namespaces the loader adds (storage, log, secrets, hostClient) and the ones the SDK adds (preferences, agents, realtime). Every member forwards; nothing in the SDK holds kernel state (packages/plugin-sdk/src/server/context.ts). This subpath also re-exports all of @get-bb/plugin-sdk/contracts; those values get one line each in 3.13 and are documented in §2.
Sources: packages/plugin-sdk/src/server/*.ts, packages/kernel-core/src/{scope,scope-impl,commands,events,activation,actor,services,contract,errors,handle}.ts, packages/kernel-loader/src/{ports,service,loader/facade,composition/service}.ts, packages/kernel-contract/src/catalog.ts. Spec: 02 §3–§7, 01 §5.4, 01 §9. As built: D1–D3, D10, D13, D15, D19 in docs/stage-2/04-kernel-as-built.md.
3.1 definePlugin and activation
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
definePlugin |
(input: TypedPluginInput<C>) => Plugin / (input: PluginInput) => Plugin |
Builds the loader's PluginDefinition: activate wrapped over pluginContext(facade), background filled {} once, product branded Symbol.for("bb.plugin-server"). The plugin id is the manifest's, never repeated in code. |
01 §2.4 | D19; define-plugin.ts |
PluginInput |
{ activate(ctx: PluginContext, config: ActivationConfig): Promise<void> | void; background?: Record<string, BackgroundService> } |
Untyped form: config is the row config as the loader validated it against the manifest JSON Schema. |
01 §2.4 | built |
TypedPluginInput<Config> |
{ config: SchemaOf<Config>; activate(ctx, config: Config); background? } |
Typed form: the row config is parsed once more through the plugin's Standard Schema, so the Config type is a parse, not an assertion. A rejection throws invalid_input from activate. |
01 §2.4 (I19) | A first activation ends failed; a reload keeps the old generation and records the error as degraded (activation.ts failSwap) |
Plugin |
PluginDefinition & { [brand]: true } |
The product; background is always present. |
01 §5.4 | built |
isPlugin |
(value: unknown) => value is Plugin |
Brand check a build or test uses to tell a server entry from an arbitrary object. | — | built |
ActivationConfig |
Readonly<JsonObject> |
The validated row config. | 02 §4.4 | built |
ActivationState |
waiting {missing} | activating | running | degraded {problems} | needs-configuration {message} | failed {error} | disposing | disposed |
The one runtime state machine; kernel/activation.changed carries its JSON form. |
02 §4.4 | built |
ActivationStatusApi |
{ needsConfiguration(message): Promise<void>; ok(): Promise<void> } |
ctx.status. needsConfiguration moves a settled generation to needs-configuration and keeps every effect; ok returns it to running or degraded. Callable at any time, in-process or worker. |
02 §4.4 | built |
Activation rules as built (activation.ts):
activate()has a 30 s budget (activationTimeoutMs); time spent waiting on an earlier-ordered activation's publish gate is not charged. ThrowingNeedsConfigurationErrorends inneeds-configurationwith effects kept; any other throw or a timeout ends infailedwith effects disposed.- Registrations publish in composition order: a generation's
provide/define/register/oncalls queue until every earlier-ordered activation in the wave settles. The returned promise settles once the registration is visible, soawait ctx.provide(...); await ctx.tryInject(...)works insideactivate(D1). - Registration conflicts that depend on other plugins (
conflict,forbiddenon a noun,reserved_name) are recorded as problems and return a no-op disposer; the row showsdegradedand everything else keeps serving.invalid_facts,invalid_contract,unknown_command, andscope_disposedstill throw (D1). - A
requiresprovider must be bound before activation starts (waiting {missing}); ausesprovider does not block. Both re-runactivate()on bind, rebind, or unbind (formulti, any candidate add or remove): the scope is disposed, a fresh scope is made, andactivateruns again. More than 8 re-runs in 10 s parks the plugin infailedwithactivation_loop(02 §4.4). A plugin that wants to survive auseschange without re-running declares nothing and callsctx.watch()instead. - Noun ownership is composition order (lowest
order, ties by plugin id); a later claimant of a manifest noun isdegraded: noun X claimed by Yand its registrations under the noun areforbidden(D2). - Every activation runs in-process in Stage 2 (
base.yamlrows andpath:installs). Worker-only gaps: declared input.transform()outputs do not reach a worker handler (D7; README deviation 3).
import { definePlugin, defineBackgroundService, sleep, NeedsConfigurationError } from "@get-bb/plugin-sdk/server";
export default definePlugin({
config: z.object({ prefix: z.string().default("note") }), // optional; omit for ActivationConfig
async activate(ctx, config) {
const db = await ctx.storage.openDatabase(MIGRATIONS);
if (!(await ctx.secrets.resolve({ name: ctx.secrets.reference("token") }).catch(() => null)))
throw new NeedsConfigurationError("set the token in Settings"); // effects kept; row shows needs-configuration
await ctx.provide(notes, handlers(db, config.prefix));
},
background: { tick: defineBackgroundService(async (_scope, signal) => { while (!signal.aborted) await sleep(60_000, signal); }) },
});3.2 PluginContext members
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
info |
ScopeInfo |
{ id: "plugin:<id>@<gen>", kind: "plugin", pluginId, generation, meta }; fixed at creation. |
02 §4.1 | built |
signal |
AbortSignal |
Aborts when the generation's scope disposes. | 02 §4.1 | built |
identity |
ScopeIdentity = { actor, session } |
Who the scope acts as outside a call: {kind: "plugin", id} with the generation's internal session (client.kind internal in-process, worker in a worker). |
02 §5.3, §7 | built |
events |
EventBus |
The bus, scope-tagged (3.5). | 02 §5 | built |
commands |
CommandBus |
The bus, scope-tagged (3.4). | 02 §6 | built |
status |
ActivationStatusApi |
needsConfiguration / ok (3.1). |
02 §4.4 | built |
provide |
<C extends AnyContract>(contract: C, handlers: HandlersOf<C>, facts?: FactsOf<C>) => Promise<Disposer> |
Registers a provider; a defineService object goes through kernel-contract's provideService (extended CallContext), a bare ServiceContract through scope.provide. facts defaults to {}. |
02 §3.2 | context.ts |
inject |
(contract, range) => Promise<HandleOf<C>> |
The bound provider, else service_unavailable. |
02 §3.3 | built; range must match the manifest's requires/uses entry |
tryInject |
(contract, range) => Promise<HandleOf<C> | null> |
Non-rejecting variant. | 02 §3.3 | built |
watch |
(contract, range, listener: (handle | null, change: BindingChange) => void) => Promise<Disposer> |
Fires now with the current binding, then on every bound / rebound / unbound. |
02 §3.3 | built; worker normalization in 3.3 |
candidates |
(contract, range) => Promise<readonly HandleOf<C>[]> |
Every live provider in arbitration order; the only lookup for a multi contract. |
02 §3.3, §3.4 | built |
facts |
(contract, range) => Promise<FactsOutputOf<C> | null> |
The bound provider's facts without calling it. | 02 §3.5 | built |
extend |
(meta: Record<string,string>, kind: ScopeInfo["kind"], identity?: ScopeIdentity) => Scope |
A child scope that shares the registry, merges meta, owns its own effect stack; disposed before the parent. Synchronous. |
02 §4.1 | built |
effect |
(register: () => Disposer | void) => Disposer |
Pushes a disposer on this scope's stack (LIFO, 5 s per disposer). On a disposed scope it throws scope_disposed. |
02 §4.3 | built |
storage |
StorageApi |
kv, database(), migrate(), openDatabase() (3.6). |
01 §9 | built |
log |
PluginLog |
debug/info/warn/error(message, fields?) (3.6). |
01 §9 | built |
secrets |
SecretsApi |
resolve({name}), reference(key) (3.7). |
02 §3.7, 01 §9 | built |
preferences |
PreferencesApi |
define(key, schema, {scope, default}), get({key, scope, threadId}) (3.7). |
02 §3.7 | built |
agents |
AgentsApi |
configure(input), contributeInstructions(text, scope?) (3.8). |
01 §5.3, 02 §3.8 | D19 |
realtime |
RealtimeApi |
declare(def), publish(name, payload) (3.5). |
02 §5.5–5.7 | D19 |
hostClient |
<C extends HostCommandsContract>(contract: C, name: string) => TypedHostClient<C> |
The plugin's own host tier, typed by its defineHostCommands contract (3.9). |
01 §3.9, 05 §4.7 | D15: unwired in core today |
scope |
Scope |
The loader facade underneath, for a caller that needs 02's raw Scope (it also carries dispose()). |
02 §3 | built |
3.3 Services: provide, inject, watch, candidates, facts
- Provide. The container validates
factsagainstcontract.facts(invalid_factsthrows). A secondprovideof a non-multicontract from the same generation is aconflictproblem; amulticontract may be provided N times with a distinctcontract.factsKeyvalue per call. Akernel/…id from a plugin scope is areserved_nameproblem. Everykind: "mutation"method derives oneside: "server"command namedmethod.command(default<pluginId>/<service>.<method>); a noun the plugin does not own is aforbiddenproblem, a taken name aconflictproblem, and the other methods still register (02 §3.2;scope-impl.ts). Amulticontract may not carry mutations (invalid_contract, D2). - Handlers. A handler receives what the container validated: in-process the method's Standard Schema ran once, defaults and transforms applied. The second argument is the
CallContext(callId,traceId,actor,session,scope,signal,command), extended by §2's fields for adefineServicehandler. Every call is timed, counted per plugin, and failure-isolated; a non-KernelErrorthrow becomesplugin_error. - Inject.
injectbinds bycontract.idandsatisfies(contract.version, range); amulticontract answersinvalid_contract("use candidates()"). The handle is built from the provider's registered contract; the SDK only types it asHandleOf<C>. - Watch. In-process the listener fires synchronously with the current binding, then on a microtask after each registry flush (D3). In a worker, core answers
invalid_contract {contractId}for an id nobody in the composition declares; the facade folds that into the in-process answer (service_unavailable/null/[]/null) and, forwatch, firesunbounditself and registers the real watch on the firstkernel/service.boundfor that id, which firesbound(I14; README deviation 2). A throwing watcher is counted and reported askernel/listener.failed; the others still fire. - Arbitration. One provider is bound per single-provider id:
replaces→ user pin (kernel/pinsmap, targetservice:<id>, written bycomposition.pin) → manifestpriority(loader fills 0; there is no runtime priority argument) → sorted plugin id (02 §3.4). Crash fallback rebinds to the next candidate and emitskernel/service.rebound; aneeds-configurationplugin stays a candidate. - Facts. Frozen for the generation; change a fact by reloading. Peers decide by facts, never by plugin id (02 §3.5).
- Handles.
ServiceHandle<C>={ id, version, providerPluginId, generation, facts, alive }plus one method per contract method;CallOverrides={ signal?, actor? (system callers only, else forbidden), idempotencyKey? }. A handle binds one(providerPluginId, generation)and never rebinds; a mutation handle call is acommands.dispatchofmethod.command, so interceptors and actor stamping cannot be bypassed in-process (02 §3.2, §3.6).
withDefaults(def, handlers) (@get-bb/plugin-sdk/server, src/server/boundary.ts; added at dc07292bf): returns the same handler table with every non-custom method's input re-validated through the contract's own schema at the plugin boundary. In-process the container already validated and filled defaults, so this is a second pass; in a worker the input arrived through contract.json's JSON Schema and the schema's transforms run only here. Handlers see one shape in both placements. A rejected input throws invalid_input with issues; a stream handler that returns no async iterable throws invalid_output. Use it as await ctx.provide(def, withDefaults(def, { ... })).
Re-runs (at dc07292bf). When a uses binding changes, kernel-core tears the dependent down and re-activates it inside one registry batch, so a dependent compares the binding before and after (the same pluginId@generation) and never observes a gap. A handle follows a same-generation re-provide instead of answering stale_handle.
3.4 Commands
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
commands.define |
(def: CommandDefinition) => Promise<Disposer> |
Registers a side: "local" definition (build it with §2's defineCommand, which fills side: "local", deadlineMs: null, origin: {kind: "client"}, all actors). A side: "server" definition throws invalid_contract: provide() is the only source of server commands. |
02 §6.3 | built; the threads server tier defines the internal thread.revive this way |
commands.register |
(name, executor: (cmd: CommandContext<In>) => Promise<Out>) => Promise<Disposer> |
Exactly one executor per name; a second is a conflict problem, a method-derived name a conflict problem, an undefined name throws unknown_command. |
02 §6.3 | built |
commands.before |
(name | "<noun>.*", interceptor: Interceptor<In, Out>, { priority? }) => Promise<Disposer> |
A waterfall step: call next(cmd), next({...cmd, input}) (only input may change; re-validated on every next), or throw to veto. |
02 §6.3, §6.4 | D3 |
commands.after |
(name | "<noun>.*", listener: (cmd, outcome: Envelope<Out>) => void | Promise<void>, { priority? }) => Promise<Disposer> |
Sees (cmd, outcome) and cannot change it. Listeners run concurrently (Promise.all), failure-isolated (kernel/listener.failed), and are awaited before the outcome returns. |
02 §6.4 step 9 | commands.ts; stage-2/01 §4 |
commands.dispatch |
(name, input: CommandInput<N>, overrides?: DispatchOverrides) => Promise<Envelope<CommandOutput<N>>> |
Runs the full sequence from this scope; never throws for a command failure — read outcome.ok / outcome.error.code. |
02 §6.4 | built: the error side is the plain KernelErrorShape, not the spec's Outcome with a KernelError instance |
commands.has |
(name) => Promise<boolean> |
Whether a definition resolves in this process (this process's side first, then the other). | 02 §6.3 | built |
CommandContext<In> |
{ name, commandId, input, actor, session, time, parent, scope, signal, deadline, notes: Map<string, JsonValue> } |
The dispatch; notes carries facts between interceptors and the executor. |
02 §6.3 | built |
Interceptor<In, Out> |
(cmd, next: (cmd?) => Promise<Out>) => Promise<Out> |
The before signature. |
02 §6.3 | built |
DispatchOverrides |
{ signal?, actor?, session? } |
actor/session are permitted only when the dispatching scope's actor is system (else forbidden). |
02 §6.3 | built |
CommandDefinition |
{ name, input, output, actors, side, destructive, deadlineMs, origin } |
Normalized; every field filled. | 02 §6.3 | built |
CommandBus |
the six methods above | ctx.commands. |
02 §6.3 | built |
Names and ownership. CommandName = <noun>.<segment>(.<segment>)*; a plugin always owns nouns that start with <pluginId>/ and owns a bare manifest noun (contributes.commands.nouns) only as the earliest claimant in composition order (D2). Reserved server nouns: kernel, composition, plugin, host, preference, secret. Wildcards match on segment boundaries: thread.* covers thread.queue.create.
Dispatch sequence as built (commands.ts):
- lookup (
unknown_command). - resolve actor/session from the scope or the enclosing call (
forbiddenfor a non-system override). def.actorscheck (forbidden).- validate input (
invalid_input). - depth cap 16 (
dispatch_depth). - build
CommandContextwithdeadline = now + deadlineMs(a nested dispatch takesmin(own, parent's remaining)). kernel/command.dispatched.- before chain.
- executor (none.
service_unavailable).- validate output (
invalid_output). - after listeners.
kernel/command.completed|.failed|.vetoed.
- Order. Hooks run priority desc → exact name before wildcard → composition order → registration order (D3).
- Veto.
throw new KernelError({ code: "vetoed", message, data: { reason } }); the kernel fillsdata.bywith the interceptor's plugin id when omitted. A veto reaches the dispatcher as{ok: false, error}and emitskernel/command.vetoed; it is never swallowed. - Budgets.
deadlineMsbounds the whole dispatch; §2'smethod()fills it fromblocking.maxMs(1 ms..60 min) or 60 s for a mutation (kernel-contract/src/define.ts). Expiry abortscmd.signaland answerstimeout. Each interceptor also has a 10 s budget (interceptorTimeoutMs) for its own work before it callsnext(); an overrun skips it, emitskernel/listener.failed, and cannot veto (D3). - Actors.
Actor={ kind: "human" \| "agent" \| "plugin" \| "system", id };Session={ id, actor, client: {kind, surface, version}, createdAt, credentialId }(02 §7). A plugin scope dispatches as{kind: "plugin", id: pluginId}; an agent tool as{kind: "agent", id: threadId}; the CLI ashumanoragent.MethodDef.actorsgates both handle calls and dispatches before any interceptor:kernel/secrets.putis["human"], everykernel/pluginsandkernel/compositionmutation is["human", "agent"], so a plugin calling them getsforbidden.
await ctx.commands.before("thread.send", async (cmd, next) => {
const head = await threads.show({ threadId: cmd.input.threadId });
if (head.providerId !== "claude-code") return next(cmd);
if (cmd.actor.kind === "agent" && cmd.input.options.permissionMode === "bypass")
throw new KernelError({ code: "vetoed", message: "agents may not bypass", data: { reason: "ceiling" } });
return next({ ...cmd, input: { ...cmd.input, actions: armPlan(cmd.input) } });
}, { priority: 0 });
await ctx.commands.after("thread.stop", (cmd, outcome) => { if (outcome.ok) void settle(cmd.input.threadId); });3.5 Events and realtime
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
events.define |
(def: EventDefinition<N>) => Promise<Disposer> |
Registers a definition (build it with §2's defineEvent). A plugin defines only <pluginId>/… names (forbidden otherwise, reserved_name for kernel/), only with scope equal to this process (invalid_contract throws), wire only on emit mode (invalid_contract); a duplicate name is a conflict problem. |
02 §5.1 | events.ts |
events.on |
(name, listener: Listener<N>, opts?: ListenOptions) => Promise<Disposer> |
Subscribes. ListenOptions = { priority? (0), global? (false), key? }. An undefined name is typed unknown and fires only for inbound wire frames. |
02 §5.1, §5.4 | built |
events.emit |
(name: EmitNames, payload) => Promise<void> |
Validates (invalid_input), delivers to every matching listener failure-isolated, resolves once all settled, then publishes a wire frame when wire.to names another process. |
02 §5.2, §5.6 | built |
events.waterfall |
(name, payload, base: (payload) => Promise<R>) => Promise<R> |
Around-middleware: listeners by priority; next(payload?) re-validates; returning without next short-circuits; a throw propagates. |
02 §5.2 | built |
events.parallel |
(name, payload) => Promise<Array<R | KernelError>> |
Promise.allSettled over listeners; rejections become KernelError values. |
02 §5.2 | built |
EventMeta |
{ name, actor, session, time, source: { pluginId, scope, process, hostId }, eventId } |
Second listener argument. actor is the current call's, else the scope identity's. |
02 §5.3 | built |
EventBus |
the five methods above | ctx.events. |
02 §5.1 | built |
realtime.declare |
(def: EventDefinition<N extends EmitNames>) => Promise<Disposer> |
events.define checked to be emit-mode with wire.to including "client"; anything else throws invalid_contract. Await it before provide: a publish of a name not yet declared throws unknown_event. hello-slot's activate order is openDatabase → preferences.define → realtime.declare → hostClient → provide (server.ts). |
02 §5.5–5.7 | D19; README deviation 13: there is no channel object |
realtime.publish |
(name, payload) => Promise<void> |
events.emit. Clients subscribe to (name, key) where key = "<wire.key.prefix>:<payload[wire.key.path]>". |
02 §5.6 | built |
RealtimeApi |
{ declare, publish } |
ctx.realtime. |
— | built |
Scope tagging: a listener on scope S sees a dispatch from S, a descendant of S, or root (inbound wire frames are re-emitted at root with meta.source.process set); global: true sees every dispatch. Same-priority listeners fire in composition order, then registration order. Emitting an undefined name throws unknown_event; a mode or process mismatch throws invalid_contract.
Kernel events a server tier may subscribe to (02 §5.5; events.ts KERNEL_EVENTS): kernel/service.bound | .rebound | .unbound {serviceId, pluginId, generation, previous}, kernel/activation.changed {pluginId, generation, state}, kernel/boot.settled, kernel/effect.failed | .late, kernel/listener.failed, kernel/wire.dropped, kernel/command.dispatched | .completed | .failed | .vetoed {command, durationMs, error}, kernel/catalog.changed, kernel/preferences.changed {key, scope, threadId, value, updatedAt}, kernel/secrets.changed {name}, kernel/ui.command | .result. kernel-store adds kernel/store.appended, kernel/store.changed, kernel/store.rows, kernel/<entity>.changed, kernel/thread.purged (D12); the threads server tier derives tail state from kernel/store.appended and reacts to kernel/thread.changed and kernel/activation.changed. Declare merged Events / Commands through @get-bb/plugin-sdk/contracts (§2).
3.6 Storage, kv, database, log
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
storage.kv |
PluginKv = { get(key) → JsonValue | null; set(key, value); delete(key); list(prefix) → {key, value}[] } |
Per-plugin rows in plugin_kv of store.db. Key ≤ 256 bytes, value ≤ 256 KiB JSON, else invalid_input. |
01 §9 | facade.ts |
storage.database |
() => Promise<SqliteHandle> |
~/.bb/plugins/data/<id>/data.db (WAL). Requires contributes.database, else precondition. One guarded handle per generation, closed on dispose. |
01 §9 | built |
storage.migrate |
(db, statements: readonly string[]) => Promise<{ applied, total }> |
Append-only ledger _bb_migrations(idx, sha256, applied_at) keyed by index; an edited applied statement is conflict. Each string is one SQL statement of any kind, run with no params — a seed INSERT is legal (hello-slot MIGRATIONS[1]). The pending statements and their ledger rows go through one db.batch(...), one BEGIN IMMEDIATE … COMMIT, so a failing statement rolls the whole pending set back. |
01 §9 | facade.ts migrate; kernel-store/src/sqlite.ts transaction; write CREATE TABLE, not IF NOT EXISTS (D13) |
storage.openDatabase |
(migrations: readonly string[]) => Promise<SqliteHandle> |
database() + migrate() in one call. |
— | README deviation 5 |
StorageApi |
PluginStorage & { openDatabase } |
ctx.storage. |
— | built |
PluginStorage |
{ kv, database(), migrate() } |
The loader's shape. | 01 §9 | built |
SqliteHandle |
{ exec(sql: string); run(sql, params: SqlParam[]) → SqlResult; get(sql, params: SqlParam[]) → SqlResultRow | null; all(sql, params: SqlParam[]) → SqlResultRow[]; batch(statements: { sql; params: SqlParam[] }[]) → SqlResult[]; close() } |
params is a required positional array for ? placeholders ([] when none; kernel-store/src/storage.ts). Async in every placement; batch is the only transaction surface (one BEGIN IMMEDIATE … COMMIT). all over 10,000 rows or 1 MiB answers precondition {reason: "payload_too_large"}. After close() every call is stale_handle. |
01 §9, 00 §3.3 | kernel-store/src/storage.ts, facade.ts |
SqlParam / SqlResultRow / SqlResult |
JsonValue | Uint8Array / Record<string, SqlParam> / { rows, changes, lastInsertRowid } |
Parameter and row shapes; a BLOB column is a Uint8Array in every placement. run fills rows for RETURNING, SELECT, PRAGMA. |
01 §9 | D13 |
log |
PluginLog = Record<"debug" | "info" | "warn" | "error", (message, fields?: JsonObject) => void> |
JSONL at plugins/data/<id>/logs/<yyyymmdd>[.<n>].jsonl with time, level, pluginId, generation; read by bb plugin logs <id>. A call after dispose is a no-op. |
01 §9 | built |
Every storage, secrets, and hostClient member checks ctx.signal.aborted first, so a promise leaked from generation N fails with scope_disposed instead of writing into generation N+1 (facade.ts).
// read one row: every column is a SqlParam and is narrowed by hand (hello-slot server.ts `valueOf`)
const row = await db.get("SELECT words, chars FROM totals WHERE id = ?", [1]); // SqlResultRow | null
const words = row?.["words"];
if (typeof words !== "number") throw new KernelError({ code: "internal", message: "totals row missing" });3.7 Preferences and secrets
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
preferences.define |
<T>(key: string, schema: SchemaOf<T>, { scope: PrefScope, default: T }) => PreferenceHandle<T> |
key is the full <pluginId>/<name> string (hello-slot: THEME_KEY = "hello-slot/theme", shared with app.tsx through contracts.ts); a key outside ^[a-z0-9][a-z0-9-]*\/[a-zA-Z][a-zA-Z0-9_-]*$ throws invalid_contract synchronously, and only a key under the plugin's own id is writable by its set (owner check below). Preference key rule (identical in §1.3 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); contributes.settings[name] 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 §9, 02 §3.7 | preferences.ts KEY_RE; stage-2/01 §5 |
PreferenceHandle<T>.get |
(args?: { threadId }) => Promise<T> |
The validated value, or the default when unset; a stored value the schema rejects is invalid_output. Thread scope needs threadId (invalid_input). |
02 §3.7 | built |
.updatedAt |
(args?) => Promise<number | null> |
The row stamp for a compare-and-set. | #29 | built |
.set |
(value: T, args?: { threadId?, expectedUpdatedAt?: number | null }) => Promise<{ updatedAt }> |
Validates (invalid_input), then kernel/preferences.set; expectedUpdatedAt omitted means null (last writer wins); a mismatch is conflict {key, updatedAt}. |
02 §3.7 | D13 |
.clear |
(args?) => Promise<void> |
kernel/preferences.clear. |
02 §3.7 | built |
.watch |
(listener: (value: T, meta: { threadId, updatedAt }) => void) => Promise<Disposer> |
kernel/preferences.changed filtered by key and scope; delivers the default on clear; a stored value the schema rejects is logged and not delivered. |
02 §3.7 | built |
PreferenceDefinition<T> |
{ key, scope, schema, default } |
The handle's descriptor fields. | — | built |
preferences.get |
({ key, scope, threadId }) => Promise<{ value: JsonValue | null, updatedAt }> |
Raw read of a key owned by others (kernel/pins, another plugin's setting). |
02 §3.7 | built |
PreferencesApi / PrefScope |
{ define, get } / "profile" | "thread" |
ctx.preferences; client and tab never reach core. |
02 §3.7 | built |
secrets.resolve |
({ name }) => Promise<{ value }> |
kernel/secrets.resolve: not_found when unset, service_unavailable when no backend is bound; counted per plugin. |
02 §3.7 | built |
secrets.reference |
(key) => string |
plugin:<pluginId>/<key>, the reference a type: "secret" setting holds. Writes go through kernel/secrets.put (actors: ["human"]), never from plugin code. |
01 §9 | README deviation 5 |
SecretsApi |
{ resolve, reference } |
ctx.secrets. |
— | built |
The kernel/preferences handle is injected once per activation and reused; a rejected injection is retried on the next call. On set/clear core checks the owner: a plugin actor may write only <pluginId>/… keys, system only kernel/…, a human any key (forbidden otherwise). A settings save emits kernel/preferences.changed and never re-runs the plugin; as built a needs-configuration row re-runs only on a change to one of its own contributes.settings keys (D8).
3.8 Agents
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
agents.configure |
(input: AgentConfiguration) => Promise<Disposer> |
One live threads/agent-config contribution for as long as the plugin runs. Held through watch("threads/agent-config", "^1"): contributes when a provider is bound (now, after threads activates, or after a threads reload to a generation that starts empty) and the disposer, also a scope effect, stops watching and revokes. Resolves once the first contribution is accepted (if a provider is bound); with no provider bound it resolves at once with the disposer (state.first stays null, agents.ts) and contributes on a later bind, so activate never parks on it; rejects only when that first call fails; later contribute/revoke failures go to ctx.log.warn. |
01 §5.3, 11b-domain-plugins.md §1.1 rule 7 | D19; README deviation 6 (watch-held, not one-shot); agents.ts |
agents.contributeInstructions |
(text, scope?) => Promise<Disposer> |
configure({ instructions: { text, mode: "append" }, scope }). |
01 §5.3 | built |
AgentConfiguration |
{ scope?, tools?, instructions?, skills?, env?, priority? } |
Author-facing; omission means "nothing for that field" and the SDK fills the contract's nulls and scope {kind: "all"}, priority 0 once. |
02 §3.8 | toContributeInput |
AgentConfigScope |
{ kind: "all" } | { kind: "project", projectId } | { kind: "thread", threadId } |
Where the contribution applies. | 11b §1.1 | built |
ToolSelection |
{ include: string[], exclude: string[] } |
A selection over expose.tool method names (tool names ^[a-zA-Z0-9_-]+$); there is no second tool registry. |
03 §6 | built |
InstructionsContribution |
{ text: string (≤ 32,768), mode: "append" | "prepend" | "replace" } |
System-prompt text. | 11b §1.1 | built |
SkillRef |
{ rootId, path } |
One skill reference; skills is SkillRef[]. |
11b §1.1 | built |
env |
Record<string, string> |
BB_* names only; a non-BB_ name rejects configure() with invalid_input before anything is contributed. The host applies them at bridge spawn. |
05 §7 | built |
ResolverRef |
{ resolver: { service, method } } |
Accepted in place of any of tools/instructions/skills/env: a query on the contributor's own service that receives the agent-config context and returns the field's literal shape. |
11b §1.1 rule 8 | built |
AgentsApi |
{ configure, contributeInstructions } |
ctx.agents. |
— | built |
The plugin must declare uses: { "threads/agent-config": "^1.0.0" }: that is also what re-runs activate() when the binding changes (02 §4.4), so a late-installed threads is seen either way. The contract object is the SDK's _pending copy of threads/agent-config (contribute and revoke only); the container binds by id, so a shape drift is type-level only. The threads server tier provides contribute/revoke/resolve keyed by the caller's call.scope and evicts by (pluginId, generation) on kernel/activation.changed (02 §3.8). Realistic usage: the fixture contributes instructions plus tools: { include: ["notes_fixture_notes_create"], exclude: [] } and keeps serving when threads is absent.
3.9 Host client
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
hostClient |
(contract: HostCommandsContract, name: string) => TypedHostClient<C> |
name is a contributes.hostRoles[].name of this plugin; one raw client per role name per generation. |
01 §3.9, 05 §4.7 | host-client.ts |
TypedHostClient.call |
(command, input, opts?: HostCallOptions) => Promise<Output> |
Validates input against the contract before the frame leaves core (invalid_input) and output after it arrives (invalid_output); an unknown command throws invalid_contract. |
05 §4.7 | built |
TypedHostClient.onSignal |
(name, listener: (payload) => void) => Disposer |
Host → core plugin.signal frames by name; a payload outside the declared schema is logged (hostClient.onSignal: payload rejected) and never delivered. |
05 §4.7 | I21 |
TypedHostClient.onExit |
(listener: (exit: HostExit) => void) => Disposer |
HostExit = { roleId, exitCode: number | null, signal: string | null }. |
05 §4.5 | built |
HostCallOptions |
{ hostId?: string | null; timeoutMs?: number | null } |
null (default) = the core's own host / the loader's default deadline. |
05 §4.7 | built |
HostClient |
{ call(command, input: JsonValue, { hostId, timeoutMs }) → JsonValue; onSignal; onExit } |
The loader's untyped transport underneath. | 01 §3.9 | built |
At 6f3d4592f the core → role rpc path from a server tier was not wired (see the wiring note below for dc07292bf): ctx.hostClient(...).call answers service_unavailable in the product; host tiers still run, and createTestPlugin wires a fake role per contributes.hostRoles row so the typed face is testable (D15, flippable; D5). The rejection is a KernelError instance with code service_unavailable (retryable: true), built by init.fail in packages/core/src/host-client.ts (no host worker, host tier not ready, or an undeclared role name) and rethrown unchanged by the typed face (server/host-client.ts); packages/core/src/core.ts now passes that factory to the loader, so whether D15 still holds in the product is an open question (§0.6).Until the kernel closes D15 on every host, try the host, then fall back in-process, and warn once:
let warned = false;
const countVia = async (text: string): Promise<Counts> => {
try { return await echo.call("host-count", { text }); } catch (err) {
if (!(err instanceof KernelError) || err.code !== "service_unavailable") throw err;
if (!warned) { warned = true; ctx.log.warn("echo role unreachable; counting in-process", { code: err.code }); }
return countText(text); // the same function the role runs
}
};Wiring (at dc07292bf). Core passes createHostClients(...).clientFor (packages/core/src/host-client.ts) to the loader, so ctx.hostClient(contract, name).call(...) reaches the plugin's rpc role process over the same published host artifact. The service_unavailable answer described above is the 6f3d4592f state (D15); keep the fallback pattern for a host that is offline.
3.10 Background services and sleep
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
defineBackgroundService |
(start: (ctx: Scope, signal: AbortSignal) => Promise<void>) => BackgroundService |
Shapes one entry of the background export. |
01 §5.4 | background.ts |
BackgroundService |
{ start(ctx: Scope, signal): Promise<void> } |
ctx is a bare 02 Scope (a child of the plugin scope, extend({service: name}, "custom")), not a PluginContext: no storage, log, preferences, or agents. |
01 §5.4 | ports.ts |
sleep |
(ms: number, signal: AbortSignal) => Promise<void> |
The idle wait of a polling loop: resolves after ms or at once on abort; never rejects, so while (!signal.aborted) { …; await sleep(30_000, signal); } exits on the guard with no catch. |
01 §5.4 | D10 |
Rules (01 §5.4; kernel-loader/src/loader/background.ts): keys of background must equal contributes.background (a difference is an invalid_contract problem and the service does not run). A non-empty contributes.background starts a separate background worker; the server entry stays in-process (D10). Each service starts after the activation reaches running/degraded/needs-configuration. Resolve → stopped (not restarted). Throw → crashed → restart with min(1000 ms × 2^n, 60 s), n reset after 5 min healthy. Throw NeedsConfigurationError → needs-configuration for the service plus ctx.status.needsConfiguration(message) for the plugin. Stop aborts the signal and waits up to 5 s. There is no kernel schedule table or tick: run a timer inside start or depend on automations/schedules.
3.11 Kernel services a plugin may call
Inject the kernel-core three by the contract objects §2 exports (preferences, secrets, telemetry, plus secretsBackend / telemetrySink to provide a backend or sink) with range "^1", and list the id in the manifest's requires/uses. The loader-owned kernel/plugins and kernel/composition and kernel-contract's kernel/contract have no contract object in the SDK surface; a plugin injects them by declaring its own defineService copy of the id (the container binds by id and builds the handle from the provider's registered contract), as the Stage 2 threads plugin does for kernel/composition and kernel/catalog.
kernel/preferences 1.0.0 (kernel-core/src/services.ts; 02 §3.7):
get {key, scope: PrefScope, threadId: string | null}(query) →{value: JsonValue | null, updatedAt: number | null};threadIdis non-null exactly forthreadscope.set {key, scope, threadId, value, expectedUpdatedAt: number | null}(mutationpreference.set) →{updatedAt}; owner check; CASnull= upsert,0= key must be unset, else must equal the stored stamp (conflict). Emitskernel/preferences.changed.clear {key, scope, threadId}(mutationpreference.clear) →null; emits the change withvalue: null.list {prefix}(query) →{entries: {key, scope, threadId, value, updatedAt}[]}.
kernel/secrets 1.0.0:
resolve {name}(query; container callers only) →{value};not_foundwhen unset.put {name, value}(mutationsecret.set,actors: ["human"]) →null; emitskernel/secrets.changed.delete {name}(mutationsecret.delete) →null.list {}(query) →{names, backend};backendis the activebackendId.
kernel/secrets.backend 1.0.0 (multi, factsKey: "backendId"; provide with provide(secretsBackend, impl: SecretsBackendImpl, {backendId}); the active backend is the first candidate, pin service:kernel/secrets.backend):
get {name}→{value: string | null};set {name, value}→null;delete {name}→null;list {}→{names}.
kernel/telemetry 1.0.0:
record {name, props: Record<string, JsonValue>}(query-shaped, never intercepted) →null; fans out to everykernel/telemetry.sinkcandidate, failures dropped; a no-op without sinks. The kernel recordskernel/command.completed|failed|vetoedandkernel/listener.faileditself.
kernel/telemetry.sink 1.0.0 (multi, factsKey: "sinkId"; provide with provide(telemetrySink, impl: TelemetrySinkImpl, {sinkId})):
record {name, props}→null.
kernel/plugins 1.0.0 (kernel-loader/src/service.ts; noun plugin; every mutation actors: ["human", "agent"], so a plugin gets forbidden):
list {}→{plugins: PluginStatusJson[]}.show {id}→{status, manifest, contract, appContributions, sourceDir, forkedFrom}.plan {source}→{packageName, pluginId, version, digest, resolution};sourceisnpm:|url:|path:|bundled:<id>.install {source, confirm: {name, version, digest} | null, force}(plugin.install) →PluginStatusJson.update {id: string | null, all, check, force}(plugin.update) →{reports: {id, current, available, refused}[]}; exactly one ofid/all.rollback {id}(plugin.rollback) →PluginStatusJson.remove {id, purge}(plugin.remove, destructive) →null.reload {id}(plugin.reload) →PluginStatusJson.fork {id, as: string | null, detach}(plugin.fork) →{newId, sourceDir, next}.revert {id}(plugin.revert) →{forkId, originalId}.enable {id}/disable {id}(plugin.enable/plugin.disable) →PluginStatusJson.devStop {id}(plugin.dev.stop) →PluginStatusJson.logs {id, follow, since: number | null}(stream) →{time, level, message, fields}lines.
kernel/composition 1.0.0 (kernel-loader/src/composition/service.ts; noun composition; mutations actors: ["human", "agent"]):
dump {row: string | null}→ the dump ({version: 1, layers, rows, arbitration, error});not_foundfor an unknown row.layer {layer: "user"}→{text: string | null, revision}.patch {ops, revision}(composition.patch) →{revision}.setRow {id, disabled, name, config, isolation, source}(composition.setRow;nullleaves a field alone) →{status}.insertRow {row: {id, name, config}, after: string | null}(composition.insertRow) →{status}.pin {target, pluginId: string | null}(composition.pin;nullclears) →{pins}; writes thekernel/pinsprofile preference throughkernel/preferences.set.pins {}→{pins: {target, pluginId, live}[]}.experiments {}→{experiments: {id, summary, enabled}[]}.
kernel/contract 1.0.0 (kernel-contract/src/catalog.ts; all queries):
manifest {}→ the CLI manifest{serverId, generation, methods, services, groups, guide}.guide {chapter: string | null}→ a guide chapter or the chapter list (no HTTP exposure).reference {pluginId}→ reference docs for one plugin's services.skillReference {activeTools: string[]}→{text, docs}(no CLI exposure).
Added at dc07292bf (next/docs/status/stage-2-domain.md §5, §7):
| Service | Method | Input → output | Notes |
|---|---|---|---|
kernel/store |
eventsAppend |
{threadId, events: NewEvent[], commandId} → {seqs, seqFrom, seqTo, headPatch, rejected, duplicate} |
04 §5.4 append; actors system | plugin; row ops are not on the wire — core publishes kernel/store.rows. |
kernel/store |
queueGet, projectsGet, environmentsGet, attachmentsGet |
{id} → the row |
Single-row getters. |
kernel/store |
threadsByEnvironment |
{environmentId, archived, limit} → threads |
Backed by the threads_environment index. |
kernel/host-runtime |
environmentsProvision (stream), environmentsReconnect, environmentsDestroy, environmentsCancel, environmentsLoaded → {envIds}, environmentsSummarize, environmentProviders → {providers: [{providerId, label, ownsRoot, optionsSchema}]}, ptyCloseForEnvironment |
the stream yields {kind: "progress", step, text, status} | {kind: "output", line} | {kind: "done", handle} |
Container-only JSON service (packages/core/src/host-runtime.ts); the environments plugin mirrors it. |
kernel/bridge-driver |
threadStart|Resume|Fork → {providerThreadId, sessionRestorable, capabilities: BridgeCapabilities | null}, threadStop {hostId, threadId, intent, activeTurnId}, threadDiscard, turnStart|Steer, toolResult {hostId, requestId, result}, interactionResolve {hostId, requestId, resolution}, maintenance, rolesStop {hostId, pluginId, …}, exec (stream, inside the caller's plugin root), events (stream of request | notification) |
— | Container-only (packages/core/src/bridge-driver.ts); core fills pluginVersion, generation, artifact, launch, limits on start; the providers plugin mirrors it. |
kernel/hosts |
list, get |
HostView gains homeDir: string | null |
05 §12. |
3.12 Errors and stale handles
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
KernelError |
class extends Error { code; message; issues: Issue[] | null; data: JsonValue | null; retryable; toJSON(): KernelErrorShape; withData(extra) } |
The one error type. Constructor input is { code, message, issues?, data?, retryable? }; retryable defaults to true for timeout and service_unavailable. Re-exported from §2. |
02 §8 | errors.ts |
NeedsConfigurationError |
new (message, data?) |
code: "needs_configuration"; thrown from activate or a background start it moves the plugin to needs-configuration with effects kept. Re-exported from §2. |
02 §4.4, 01 §5.4 | built |
KernelErrorShape / Envelope<T> / Result<T> / Issue |
wire shapes | Envelope = { ok: true, result } | { ok: false, error: KernelErrorShape }; Result is the same type; Issue = { path, message }. |
02 §8 | built |
Closed kernel codes (KERNEL_ERROR_CODES): invalid_input, invalid_output, invalid_facts, invalid_contract, reserved_name, unknown_method, unknown_command, unknown_event, not_found, unauthenticated, forbidden, vetoed, conflict, precondition, service_unavailable, needs_configuration, stale_handle, scope_disposed, timeout, cancelled, dispatch_depth, activation_loop, plugin_error, internal. A plugin's own codes are <pluginId>/<code> with a declared HTTP status (default 500). Any non-KernelError thrown by a handler, interceptor, or listener becomes plugin_error {pluginId}; the stack never crosses a boundary.
Stale handles and disposal (02 §3.6, §4.3, §4.4; handle.ts, activation.ts, facade.ts):
- A handle call checks, in order: consumer scope disposed →
scope_disposed; provider generation no longer live →stale_handle {serviceId, pluginId, generation}; unknown method →unknown_method; actor kind not inMethodDef.actors→forbidden; input →invalid_input.handle.aliveisentry.alive && !consumer.disposed. - Teardown of a generation marks its provider entries dead first (new calls answer
stale_handle), drains in-flight calls up to 5 s (drainTimeoutMs; a timeout emitskernel/effect.failed), then disposes the scope: children (reverse creation order) → effects LIFO (5 s each; a throw or timeout emitskernel/effect.failedand disposal continues) →signal.abort(). A generation swap is one step: old unbound, new bound, dependents re-run once; consumers never observe "no provider" between two generations of one plugin. effect()on a disposed scope throwsscope_disposed; an effect registered during disposal is disposed at once and emitskernel/effect.late. Queued publishes of a removed generation reject withscope_disposed.- The facade's
storage,secrets, andhostClientmembers throwscope_disposedoncectx.signalis aborted; a closed database handle answersstale_handle;logbecomes a no-op. Apreferences.definehandle keeps working across the generation's life because thekernel/preferencesprovider is kernel-owned and never rebinds. - A consumer that holds a handle across a provider reload must re-inject (or hold it through
watch, which deliversrebound); arequires/usesdeclaration re-runsactivate()instead.
3.13 Types index
Server-tier types not tabled above, one line each:
| Type | Meaning |
|---|---|
PluginContext |
The 3.2 table; ctx in activate. |
Scope |
02's container scope (info, signal, identity, events, commands, status, provide…facts, extend, effect, dispose); what ctx.scope and a background start receive. |
ScopeInfo |
{ id, kind: "root" | "plugin" | "thread" | "turn" | "pane" | "custom", pluginId, generation, meta }. |
ScopeIdentity |
{ actor: Actor, session: Session }. |
ServiceHandle<C> |
HandleInfo & HandleMethods for a bare ServiceContract (3.3); HandleOf<C> is the SDK's typed face over the same object. |
BindingChange |
{ kind: "bound" | "rebound" | "unbound", providerPluginId, generation }. |
CallOverrides |
{ signal?, actor?, idempotencyKey? } on every handle method call (3.3). |
Disposer |
() => void | Promise<void>. |
PluginKv, PluginLog, PluginStorage, StorageApi, SecretsApi |
3.6 and 3.7. |
SqliteHandle, SqlParam, SqlResult, SqlResultRow |
3.6. |
PreferenceDefinition<T>, PreferenceHandle<T>, PreferencesApi, PrefScope |
3.7. |
AgentsApi, AgentConfiguration, AgentConfigScope, ToolSelection, InstructionsContribution, SkillRef, ResolverRef |
3.8. |
RealtimeApi, EventBus, EventMeta, ListenOptions, Listener<N> |
3.5. |
CommandBus, CommandContext<In>, CommandDefinition, Interceptor<In, Out>, DispatchOverrides |
3.4. |
HostClient, HostExit, HostCallOptions, TypedHostClient<C> |
3.9. |
BackgroundService |
3.10. |
ActivationConfig, ActivationState, ActivationStatusApi, Plugin, PluginInput, TypedPluginInput<Config> |
3.1. |
Re-exports of @get-bb/plugin-sdk/contracts (documented in §2; one line each here for the lock):
| Export | One line |
|---|---|
defineService, method, defineContract, defineEvent, defineCommand, defineHostCommands |
Contract authoring (§2). defineCommand yields the side: "local" definition commands.define accepts (3.4). |
isServiceDef, contractOf |
Tell a ServiceDef from a bare ServiceContract; unwrap to the bare contract (§2). |
contractJsonOf, contractJsonText, CONTRACTS_SUBPATH, contractsSpecifier, pluginPackageName |
dist/contract.json generation and the published-contracts convention (§2). |
parsePluginCatalog, pluginCatalogSchema, pluginCatalogEntrySchema |
The agent-readable catalog parser and schemas (§2). |
jsonValueSchema, jsonObjectSchema |
Standard Schemas for JsonValue / JsonObject (§2). |
KernelError, NeedsConfigurationError |
3.12 (§2 owns the definition). |
preferences, secrets, secretsBackend, telemetry, telemetrySink |
The kernel hook-service contract objects 3.11 injects or provides (§2). |
Types AnyContract, FactsOf, FactsOutputOf, HandleOf, HandlersOf, SchemaOf, ServiceDefHandle |
The SDK handle/typing helpers PluginContext methods use (§2). |
Types ServiceDef, ServiceInput, ServiceHandlers, ServiceContract, ServiceId, MethodDef, MethodInput, MethodKind, MethodHandler, CustomHandler, Expose, Auth, Target, ToolMeta, ToolPresentation, CliMethodMeta, CliFieldMeta, ServiceCliMeta, RenderCtx, RenderText, PluginHttpRequest, PluginHttpResponse |
kernel-contract's authoring types (§2). |
Types ClientCommandInput, HostCommandDef, HostCommandsContract, HostCommandsInput, HostSignalDef |
defineCommand / defineHostCommands inputs and products (§2; the host contract is what hostClient takes, 3.9). |
Types ContractJson, ContractJsonService, ContractJsonMethod, ContractJsonCliField, PluginCatalog, PluginCatalogEntry |
contract.json and catalog shapes (§2). |
Types Events, EventDecl, EventDefinition, EventDefinitionInput, EventMode, EventName, Commands, CommandInput, CommandOutput, CommandName, BbServices, SlotEntry, SlotMap |
The declaration-merging hooks a contracts module augments (§2). |
Types Actor, Session, CallContext, Envelope, Result, Issue, KernelErrorShape, JsonValue, JsonObject, JsonSchema, StandardSchemaV1, PrefScope, SecretsBackendImpl, TelemetrySinkImpl |
kernel-core types a server tier names (3.4, 3.11, 3.12; §2). |