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. Throwing NeedsConfigurationError ends in needs-configuration with effects kept; any other throw or a timeout ends in failed with effects disposed.
  • Registrations publish in composition order: a generation's provide/define/register/on calls queue until every earlier-ordered activation in the wave settles. The returned promise settles once the registration is visible, so await ctx.provide(...); await ctx.tryInject(...) works inside activate (D1).
  • Registration conflicts that depend on other plugins (conflict, forbidden on a noun, reserved_name) are recorded as problems and return a no-op disposer; the row shows degraded and everything else keeps serving. invalid_facts, invalid_contract, unknown_command, and scope_disposed still throw (D1).
  • A requires provider must be bound before activation starts (waiting {missing}); a uses provider does not block. Both re-run activate() on bind, rebind, or unbind (for multi, any candidate add or remove): the scope is disposed, a fresh scope is made, and activate runs again. More than 8 re-runs in 10 s parks the plugin in failed with activation_loop (02 §4.4). A plugin that wants to survive a uses change without re-running declares nothing and calls ctx.watch() instead.
  • Noun ownership is composition order (lowest order, ties by plugin id); a later claimant of a manifest noun is degraded: noun X claimed by Y and its registrations under the noun are forbidden (D2).
  • Every activation runs in-process in Stage 2 (base.yaml rows and path: 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 facts against contract.facts (invalid_facts throws). A second provide of a non-multi contract from the same generation is a conflict problem; a multi contract may be provided N times with a distinct contract.factsKey value per call. A kernel/… id from a plugin scope is a reserved_name problem. Every kind: "mutation" method derives one side: "server" command named method.command (default <pluginId>/<service>.<method>); a noun the plugin does not own is a forbidden problem, a taken name a conflict problem, and the other methods still register (02 §3.2; scope-impl.ts). A multi contract 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 a defineService handler. Every call is timed, counted per plugin, and failure-isolated; a non-KernelError throw becomes plugin_error.
  • Inject. inject binds by contract.id and satisfies(contract.version, range); a multi contract answers invalid_contract ("use candidates()"). The handle is built from the provider's registered contract; the SDK only types it as HandleOf<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, for watch, fires unbound itself and registers the real watch on the first kernel/service.bound for that id, which fires bound (I14; README deviation 2). A throwing watcher is counted and reported as kernel/listener.failed; the others still fire.
  • Arbitration. One provider is bound per single-provider id: replaces → user pin (kernel/pins map, target service:<id>, written by composition.pin) → manifest priority (loader fills 0; there is no runtime priority argument) → sorted plugin id (02 §3.4). Crash fallback rebinds to the next candidate and emits kernel/service.rebound; a needs-configuration plugin 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 a commands.dispatch of method.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):

  1. lookup (unknown_command).
  2. resolve actor/session from the scope or the enclosing call (forbidden for a non-system override).
  3. def.actors check (forbidden).
  4. validate input (invalid_input).
  5. depth cap 16 (dispatch_depth).
  6. build CommandContext with deadline = now + deadlineMs (a nested dispatch takes min(own, parent's remaining)).
  7. kernel/command.dispatched.
  8. before chain.
  9. executor (none.
  10. service_unavailable).
  11. validate output (invalid_output).
  12. after listeners.
  13. 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 fills data.by with the interceptor's plugin id when omitted. A veto reaches the dispatcher as {ok: false, error} and emits kernel/command.vetoed; it is never swallowed.
  • Budgets. deadlineMs bounds the whole dispatch; §2's method() fills it from blocking.maxMs (1 ms..60 min) or 60 s for a mutation (kernel-contract/src/define.ts). Expiry aborts cmd.signal and answers timeout. Each interceptor also has a 10 s budget (interceptorTimeoutMs) for its own work before it calls next(); an overrun skips it, emits kernel/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 as human or agent. MethodDef.actors gates both handle calls and dispatches before any interceptor: kernel/secrets.put is ["human"], every kernel/plugins and kernel/composition mutation is ["human", "agent"], so a plugin calling them gets forbidden.
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 openDatabasepreferences.definerealtime.declarehostClientprovide (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 NeedsConfigurationErrorneeds-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}; threadId is non-null exactly for thread scope.
  • set {key, scope, threadId, value, expectedUpdatedAt: number | null} (mutation preference.set) → {updatedAt}; owner check; CAS null = upsert, 0 = key must be unset, else must equal the stored stamp (conflict). Emits kernel/preferences.changed.
  • clear {key, scope, threadId} (mutation preference.clear) → null; emits the change with value: null.
  • list {prefix} (query) → {entries: {key, scope, threadId, value, updatedAt}[]}.

kernel/secrets 1.0.0:

  • resolve {name} (query; container callers only) → {value}; not_found when unset.
  • put {name, value} (mutation secret.set, actors: ["human"]) → null; emits kernel/secrets.changed.
  • delete {name} (mutation secret.delete) → null.
  • list {} (query) → {names, backend}; backend is the active backendId.

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 every kernel/telemetry.sink candidate, failures dropped; a no-op without sinks. The kernel records kernel/command.completed|failed|vetoed and kernel/listener.failed itself.

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}; source is npm: | 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 of id/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_found for 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; null leaves a field alone) → {status}.
  • insertRow {row: {id, name, config}, after: string | null} (composition.insertRow) → {status}.
  • pin {target, pluginId: string | null} (composition.pin; null clears) → {pins}; writes the kernel/pins profile preference through kernel/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 in MethodDef.actorsforbidden; input → invalid_input. handle.alive is entry.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 emits kernel/effect.failed), then disposes the scope: children (reverse creation order) → effects LIFO (5 s each; a throw or timeout emits kernel/effect.failed and 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 throws scope_disposed; an effect registered during disposal is disposed at once and emits kernel/effect.late. Queued publishes of a removed generation reject with scope_disposed.
  • The facade's storage, secrets, and hostClient members throw scope_disposed once ctx.signal is aborted; a closed database handle answers stale_handle; log becomes a no-op. A preferences.define handle keeps working across the generation's life because the kernel/preferences provider 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 delivers rebound); a requires/uses declaration re-runs activate() 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, providefacts, 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).