Commands and interception

Every mutation in bb is a command on one bus. thread.send is a command, thread.create is a command, and the bump method from the introduction is a command. A command has a name, an actor, a validated input, and one outcome. This page shows how a plugin hooks the commands bb already has: it inspects a dispatch before it runs, rewrites the input, vetoes it, reacts after it completes, and dispatches commands of its own from a background service.

Use this when

  • Block sends to a frozen thread. The project is archived, or your own policy freezes it, and the send never reaches the executor.
  • Add a footer to every prompt. A human or an agent sends a prompt; your text rides along as one more part.
  • Post to Slack when a thread goes idle. The trigger is a status transition on the thread row, not a command.
  • Auto-archive threads older than N days. A background service runs the sweep once an hour through the bus.
  • Audit every thread.create. One log line per dispatch, with the actor and the duration.

What you build

A server-tier plugin guard. It has one service, guard/janitor, with one mutation, sweep. It hooks two bb commands (thread.send, thread.create), listens to two kernel events (kernel/thread.changed, kernel/command.completed), and runs a background service that dispatches guard/janitor.sweep once an hour. The sweep dispatches thread.archive for each stale thread.

Five of the bb commands a plugin can hook (the threads and providers contracts merge more: thread.unarchive, .update, .delete, .fork, .compact, .editMessage, .revive, interaction.request), with the input the interceptor sees (Reference §3.4; the Stage 2 threads and providers contracts):

Command Input (validated, defaults filled) Output
thread.create threads.methods.spawn.input: prompt, projectId, providerId, environment, permissionMode, title, … Thread
thread.send { threadId, input: PromptInput[], mode: "auto"|"steer"|"queue"|"new-turn", options, actions: string[], clientRequestId } { turnId, seq, queued }
thread.stop { threadId, interrupt: boolean } { status }
thread.archive { threadId: string | null, all: boolean, projectId: string | null } { archived: string[] }
interaction.resolve { interactionId, resolution: { kind: "approval" | "answer" | "submitted" | "interrupted", … } } Interaction

Command names are <noun>.<verb>. A plugin owns every noun under <pluginId>/ (guard/janitor), so guard/janitor.sweep is yours. The bare nouns thread and interaction belong to the plugins that claim them first in composition order; you hook them, you do not define them.

Steps

1. Declare the edges and the settings in package.json

requires makes activation wait until threads and projects are bound. background names the service export.

{
  "name": "@bb-local/guard",
  "version": "0.1.0",
  "type": "module",
  "exports": { "./contracts": { "source": "./src/contracts.ts", "default": "./dist/contracts.mjs" } },
  "files": ["dist", "src"],
  "bb": {
    "id": "guard",
    "name": "Guard",
    "description": "Send policy, an idle notifier, and a janitor for old threads.",
    "category": "productivity",
    "server": "./src/server.ts",
    "contracts": "./src/contracts.ts",
    "provides": { "guard/janitor": { "version": "1.0.0" } },
    "requires": { "threads/threads": "^1.0.0", "projects/projects": "^1.0.0" },
    "contributes": {
      "background": ["janitor"],
      "cli": { "commands": ["guard"] },
      "settings": {
        "frozenProjects": { "type": "list", "item": "string", "label": "Frozen projects", "default": [] },
        "footer": { "type": "string", "label": "Prompt footer", "default": "" },
        "slackWebhook": { "type": "secret", "label": "Slack webhook URL", "required": false }
      }
    }
  },
  "dependencies": {
    "@get-bb/plugin-sdk": "1.0.0-next.0",
    "@get-bb/plugin-threads": "*",
    "@get-bb/plugin-projects": "*",
    "zod": "4.3.6"
  }
}

2. Define the service and the merges in src/contracts.ts

The service gives the sweep a command name, a CLI word (bb guard sweep), and an HTTP route. The Commands merge types your own dispatch; the Events merge types the kernel entity event you listen to (kernel-core pre-declares kernel/command.* and kernel/preferences.changed, not the store's kernel/<entity>.changed family).

import { defineService, method } from "@get-bb/plugin-sdk/contracts";
import type { EventDecl, JsonObject } from "@get-bb/plugin-sdk/contracts";
import { z } from "zod";

export const FROZEN_KEY = "guard/frozenProjects";
export const FOOTER_KEY = "guard/footer";
export const sweepOutput = z.strictObject({ archived: z.array(z.string()) });
export type SweepOutput = z.infer<typeof sweepOutput>;

export const janitor = defineService({
  id: "guard/janitor",
  version: "1.0.0",
  summary: "Archive threads nobody touched for a while",
  cli: { group: ["guard"] },
  methods: {
    sweep: method({
      kind: "mutation",
      summary: "Archive threads whose last update is older than `days`",
      input: z.strictObject({ days: z.number().int().min(1).default(14) }),
      output: sweepOutput,
      renderText: (r) => `archived ${r.archived.length} thread(s)`,
    }),
  },
});

declare module "@get-bb/plugin-sdk/contracts" {
  interface Commands {
    "guard/janitor.sweep": { input: { days?: number }; output: SweepOutput };
  }
  interface Events {
    "kernel/thread.changed": EventDecl<JsonObject, "emit">;
  }
}

3. Hook, listen, and dispatch in src/server.ts

Inject the two services, define the preferences, then register the hooks. Every registration is an effect; bb disposes them when the plugin reloads.

import { definePlugin, defineBackgroundService, sleep, withDefaults, KernelError, jsonObjectSchema } from "@get-bb/plugin-sdk/server";
import { threads } from "@get-bb/plugin-threads/contracts";
import { projects } from "@get-bb/plugin-projects/contracts";
import { z } from "zod";
import { FOOTER_KEY, FROZEN_KEY, janitor } from "./contracts.js";

const threadChanged = z.object({ id: z.string(), patch: jsonObjectSchema });

export default definePlugin({
  async activate(ctx) {
    const threadsSvc = await ctx.inject(threads, "^1.0.0");
    const projectsSvc = await ctx.inject(projects, "^1.0.0");
    const frozen = ctx.preferences.define(FROZEN_KEY, z.array(z.string()), { scope: "profile", default: [] });
    const footer = ctx.preferences.define(FOOTER_KEY, z.string(), { scope: "profile", default: "" });

    // (a) veto: a send into an archived thread, a deleted project, or a frozen project never reaches the executor
    await ctx.commands.before("thread.send", async (cmd, next) => {
      const send = threads.methods.send.input.safeParse(cmd.input);
      if (!send.success) return next();
      const thread = await threadsSvc.show({ threadId: send.data.threadId });
      const project = await projectsSvc.show({ projectId: thread.projectId });
      const reason =
        thread.archivedAt !== null ? "thread-archived"
        : project.deletedAt !== null ? "project-deleted"
        : (await frozen.get()).includes(thread.projectId) ? "project-frozen"
        : null;
      if (reason !== null)
        throw new KernelError({ code: "vetoed", message: `guard: ${reason}`, data: { reason, threadId: thread.id } });
      return next();
    }, { priority: 10 });

    // (b) rewrite: append the footer as one more text part; only `input` may change
    await ctx.commands.before("thread.send", async (cmd, next) => {
      const send = threads.methods.send.input.safeParse(cmd.input);
      const text = await footer.get();
      if (!send.success || text === "" || send.data.input.length === 0) return next();
      const input = [...send.data.input, { kind: "text" as const, text: `\n\n${text}` }];
      return next({ ...cmd, input: { ...send.data, input } });
    }, { priority: -10 });

    // (c) react: an audit line per thread.create, from the after listener and from the kernel event
    await ctx.commands.after("thread.create", (cmd, outcome) => {
      ctx.log.info("thread.create", {
        actor: cmd.actor.kind,
        ok: outcome.ok,
        threadId: outcome.ok ? outcome.result.id : null,
        code: outcome.ok ? null : outcome.error.code,
      });
    });
    await ctx.events.on("kernel/command.completed", (payload) => {
      if (payload.command.name !== "thread.create") return;
      ctx.log.info("thread.create completed", { commandId: payload.command.commandId, durationMs: payload.durationMs });
    });

    // (d) a status transition: the row patch carries `status`; post to Slack when it becomes idle
    const notify = async (threadId: string): Promise<void> => {
      const hook = await ctx.secrets.resolve({ name: ctx.secrets.reference("slackWebhook") }).catch(() => null);
      if (hook === null) return;
      const thread = await threadsSvc.show({ threadId });
      await fetch(hook.value, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ text: `${thread.title ?? threadId} is idle` }),
      });
    };
    await ctx.events.on("kernel/thread.changed", (payload) => {
      const parsed = threadChanged.safeParse(payload);
      if (!parsed.success || parsed.data.patch["status"] !== "idle") return;
      void notify(parsed.data.id).catch((error: unknown) => ctx.log.warn("slack post failed", { error: String(error) }));
    });

    // (e) dispatch: the sweep archives through the bus, so every interceptor of thread.archive sees it
    await ctx.provide(janitor, withDefaults(janitor, {
      sweep: async ({ days }) => {
        const before = Date.now() - days * 86_400_000;
        const stale = await threadsSvc.list({ projectId: null, archived: "exclude", visibility: "visible", before, limit: 100 });
        const archived: string[] = [];
        for (const row of stale) {
          const outcome = await ctx.commands.dispatch("thread.archive", { threadId: row.id, all: false, projectId: null });
          if (outcome.ok) archived.push(...outcome.result.archived);
          else ctx.log.warn("sweep: archive refused", { threadId: row.id, code: outcome.error.code });
        }
        return { archived };
      },
    }));
  },
  background: {
    janitor: defineBackgroundService(async (scope, signal) => {
      while (!signal.aborted) {
        await scope.commands.dispatch("guard/janitor.sweep", { days: 14 });
        await sleep(60 * 60_000, signal);
      }
    }),
  },
});

4. Build, install, try it

bb plugin dev . installs the directory and reloads it on every change; the other lines exercise each hook.

bb plugin dev .
bb preferences --help                        # set guard/footer and guard/frozenProjects, or use Settings → Guard
bb secret put plugin:guard/slackWebhook      # the reference the `secret` setting holds
bb thread send thr_frozen "hello" --json     # {"ok":false,"error":{"code":"vetoed",...}} when that thread is frozen; from an agent shell: --self "hello"
bb guard sweep --days 30                     # archived 3 thread(s)
bb plugin logs guard --follow

What happens at runtime

A dispatch runs the sequence in Reference §3.4. In order: the bus looks the name up (unknown_command), resolves the actor from the dispatching scope or the enclosing call, checks the definition's actors (forbidden), validates the input (invalid_input), checks the nesting depth (cap 16), builds the CommandContext with a deadline, and emits kernel/command.dispatched. Then the before chain runs, then the executor, then the output is validated, then every after listener runs, then one of kernel/command.completed, .failed, .vetoed.

  • The before chain is a waterfall. Hooks run by priority descending, then exact name before wildcard (thread.*), then composition order, then registration order (D3). Your veto at priority 10 runs before your footer at -10, and before any plugin that registered at 0.
  • next() continues with the same context. next({ ...cmd, input }) continues with a new input, which the bus re-validates against the command's schema. Only input can change; actor, session, and scope are the kernel's.
  • A throw vetoes. code: "vetoed" reaches the dispatcher as { ok: false, error } and emits kernel/command.vetoed; the kernel fills data.by with your plugin id when you omit it. Any other throw fails the command the same way, with that code (a non-KernelError becomes plugin_error).
  • Each interceptor has a 10 s budget for its own work before it calls next() (interceptorTimeoutMs). An overrun skips the interceptor, emits kernel/listener.failed, and cannot veto (D3). The whole dispatch has a deadline: method() fills it from blocking.maxMs (thread.create declares 20 min) or 60 s for a mutation. Expiry aborts cmd.signal and answers timeout.
  • after listeners run concurrently, failure-isolated, and are awaited before the outcome returns to the caller. A slow listener delays every sender's response.
  • dispatch never throws for a command failure. It returns the Envelope: { ok: true, result } or { ok: false, error: KernelErrorShape }. As built, the error side is the plain wire shape, not the spec's Outcome with a KernelError instance; wrap it with new KernelError(outcome.error) when you need to rethrow.
  • The actor is stamped, not chosen. A plugin scope dispatches as { kind: "plugin", id: "guard" }; an agent tool as { kind: "agent", id: threadId }; the CLI and the app as human. DispatchOverrides.actor is honoured only for a system caller; anyone else gets forbidden.
  • A handle call of a mutation (threadsSvc.archive(...)) is the same dispatch. Interceptors and actor stamping cannot be bypassed in-process.
  • Status transitions are not commands. They arrive as kernel/thread.changed { id, patch } (the row: status, title, archivedAt, …) and kernel/thread-head.changed { threadId, patch } (the head: activeTurnId, lastSeq, turnCount, previews). A turn settling clears activeTurnId in the head patch and moves the row's status to idle.

Pitfalls

  • Do not define thread.* or interaction.* commands. A server command comes only from provide() of a mutation, and its noun must be yours: <pluginId>/… always, a bare contributes.commands.nouns entry only as the earliest claimant in composition order (D2). A later claimant is degraded and its registrations under that noun are forbidden. The reserved nouns are kernel, composition, plugin, host, preference, secret.
  • The cmd.input type comes from the Commands merge and is z.input<…>: defaulted fields read as optional even though the bus filled them. Re-parse with the method's own schema, as the example and the provider-claude-code plugin do, and work with the output type.
  • before("thread.send") sees every send: the composer, the CLI, an agent tool, the queue pump (mode: "queue"), and a fork's first turn. Branch on cmd.actor.kind or mode when a policy applies to some of them.
  • Keep after listeners fast. They are awaited. Start slow work with void slowWork().catch(...) and return.
  • A registration conflict (conflict, a forbidden noun, reserved_name) is recorded as a problem and returns a no-op disposer; the row shows degraded and the rest keeps serving. invalid_contract, unknown_command, and scope_disposed still throw (D1).
  • requires re-runs activate() when threads rebinds (a reload, a fork); your hooks are re-registered in the new generation. Hold nothing across generations in module scope.
  • A background start receives a bare Scope: commands, events, inject, no log, no storage, no preferences (Reference §3.10). Put the logging in the handler the service dispatches, as the sweep does. There is no kernel schedule table; sleep(ms, signal) is the loop's idle wait and resolves at once on stop.
  • A mutation's actors gate runs before any interceptor. kernel/secrets.put is ["human"] and every kernel/plugins and kernel/composition mutation is ["human", "agent"], so a plugin dispatching them gets forbidden.
  • kernel/command.* events are server-only; they are not wired to browsers. kernel/thread.changed is, keyed thread:<id> (Guide 6).

See also

  • Reference §2.5 (defineCommand, Commands, CommandName), §2.8 (KernelError, Envelope, Actor).
  • Reference §3.4 (the bus), §3.5 (kernel events a server tier may subscribe to), §3.10 (background services), §3.12 (error codes).
  • Reference Appendix A: D1, D2, D3.
  • Guide 5 for the preferences and the secret this plugin reads; Guide 6 for the same events in the browser.