Getting started: a plugin from an empty directory

This page takes you from an empty directory to a running plugin that answers on HTTP, the CLI, and the SDK. You write the package by hand, build it with bb plugin build, install it with bb plugin dev, and read what bb thinks of it with bb plugin show and bb composition dump. The plugin is the intro's counter, trimmed to its server and contracts tiers; add the app tier from the intro (§2.4) when you want the sidebar button.

Use this when

  • A private plugin for my team. You keep the source in your own repository and install it from a checkout or a tarball, never from npm.
  • Try a plugin from a git checkout. Someone sent you a branch; you want it running in your bb in five minutes and gone in one.
  • Ship a plugin to npm. You want bb plugin install npm:@acme/bb-plugin-counter to work for people who have never seen your code.
  • Add one CLI word and one service to bb without touching bb. The counter is small on purpose; every later page grows it.

What you build

One npm-shaped package with a bb block, two source files, three config files, and one test; bb plugin build turns it into dist/.

Files: package.json, tsconfig.json, tsconfig.types.json, vitest.config.ts, src/contracts.ts, src/server.ts, src/contracts.test.ts.

Steps

1. Write the package by hand

bb plugin new is a spec verb that @bb/plugin-build does not ship (Reference §0.6, §1.7). Create the directory and the manifest yourself. The bb block holds what bb must know before any code runs; everything else is discovered from dist/.

// package.json
{
  "name": "@bb-local/counter",
  "version": "0.1.0",
  "type": "module",
  "exports": { "./contracts": { "source": "./src/contracts.ts", "types": "./dist/types/contracts.d.ts", "default": "./dist/contracts.mjs" } },
  "files": ["dist", "src"],
  "bb": {
    "id": "counter",
    "name": "Counter",
    "description": "Count things. Agents can bump the counter as a tool.",
    "category": "developer",
    "server": "./src/server.ts",
    "contracts": "./src/contracts.ts",
    "provides": { "counter/counter": { "version": "1.0.0" } },
    "contributes": {
      "database": true,
      "cli": { "commands": ["counter"] },
      "settings": { "step": { "type": "number", "label": "Default step", "default": 1 } }
    }
  },
  "dependencies": { "@get-bb/plugin-sdk": "1.0.0-next.0", "zod": "4.3.6" },
  "devDependencies": { "@types/node": "^22.15.0", "typescript": "^5.8.0", "vitest": "^3.0.0" }
}

The parser is strict (Reference §1.2, §1.4): the id matches ^[a-z0-9][a-z0-9-]{0,63}$ and is not reserved; at least one of server, app, host is set; contracts is set whenever provides is non-empty; every provides id starts with <id>/; an unknown key fails with its path. @bb-local/<id> is the package name for a local plugin; npm: refuses that scope, so rename before you publish (step 11).

2. Add the TypeScript and vitest starters

The reference inlines hello-slot's configs (Reference §1.5). customConditions: [] matters: without it your editor resolves the SDK's source condition instead of its types.

// tsconfig.json
{ "compilerOptions": { "strict": true, "target": "ES2023", "lib": ["ES2023", "DOM", "DOM.Iterable"],
    "module": "NodeNext", "moduleResolution": "NodeNext", "jsx": "react-jsx", "types": ["node"],
    "isolatedModules": true, "verbatimModuleSyntax": true, "skipLibCheck": true, "customConditions": [],
    "noEmit": true, "rootDir": "src", "outDir": "dist/types" }, "include": ["src"] }
// tsconfig.types.json — emits dist/types/contracts.d.ts so sibling plugins get typed imports
{ "extends": "./tsconfig.json",
  "compilerOptions": { "noEmit": false, "declaration": true, "emitDeclarationOnly": true },
  "include": ["src/contracts.ts"] }
// vitest.config.ts — every file runs in node; an app test installs happy-dom itself (Reference §9.3)
import { defineConfig } from "vitest/config";
export default defineConfig({ test: { include: ["src/**/*.test.ts"], testTimeout: 30_000, hookTimeout: 60_000 } });

3. Define the contract

src/contracts.ts is the whole public surface: schemas, no handlers. Other plugins import only this module (@bb-local/counter/contracts).

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

export const STEP_KEY = "counter/step";
export const valueSchema = z.strictObject({ value: z.number().int() });
export type Value = z.infer<typeof valueSchema>;

export const counter = defineService({
  id: "counter/counter",
  version: "1.0.0",
  summary: "Keep one number",
  cli: { group: ["counter"] },
  methods: {
    get: method({ summary: "Read the counter", input: z.strictObject({}), output: valueSchema, renderText: (v) => `count ${v.value}` }),
    bump: method({
      kind: "mutation",
      summary: "Add to the counter",
      input: z.strictObject({ by: z.number().int().min(1).nullable().default(null).describe("How much to add; default: the step setting") }),
      output: valueSchema,
      renderText: (v) => `count ${v.value}`,
    }),
  },
});

4. Provide the service

src/server.ts binds handlers to the contract. activate runs once per load; every registration is an effect that bb disposes in reverse order when the plugin reloads or is disabled.

import { definePlugin, withDefaults } from "@get-bb/plugin-sdk";
import { z } from "zod";
import { counter, STEP_KEY } from "./contracts.js";

const MIGRATIONS = [
  "CREATE TABLE counter (id INTEGER PRIMARY KEY CHECK (id = 1), value INTEGER NOT NULL)",
  "INSERT INTO counter (id, value) VALUES (1, 0)",
];

export default definePlugin({
  async activate(ctx) {
    const db = await ctx.storage.openDatabase(MIGRATIONS);
    const step = ctx.preferences.define(STEP_KEY, z.number().int().min(1), { scope: "profile", default: 1 });
    await ctx.provide(counter, withDefaults(counter, {
      get: async () => ({ value: Number((await db.get("SELECT value FROM counter WHERE id = 1", []))?.["value"] ?? 0) }),
      bump: async ({ by }) => {
        const amount = by ?? (await step.get());
        const updated = await db.run("UPDATE counter SET value = value + ? WHERE id = 1 RETURNING value", [amount]);
        const value = Number(updated.rows[0]?.["value"]);
        ctx.log.info("bumped", { value });
        return { value };
      },
    }));
  },
});

5. Build

bb plugin build . runs one esbuild pass per tier, writes dist/ atomically, and prints the artifact digest. The types script is the stand-in for the spec-only bb plugin types.

pnpm install
bb plugin build . && tsc -p tsconfig.types.json   # dist/: manifest.json meta.json contract.json server.mjs contracts.mjs types/

manifest.json is the resolved manifest with every default filled; meta.json carries sdkMajor, pluginId, builtAt, and per-file digests; contract.json is the data form of your services; server.mjs is the only file the loader imports (Reference §1.7). A provides id that no exported service matches, a sibling import other than /contracts, or an /app import from a Node tier fails the build.

6. Install and watch with bb plugin dev

With bb-server running, bb plugin dev . installs the directory as a path: source on the first successful build, then watches (300 ms debounce), rebuilds unminified with sourcemaps, and reloads the plugin on every change.

bb plugin dev .            # installs path:<dir>, watches, reloads
bb counter bump --by 2     # count 2
bb counter get --json      # {"value": 2}
curl "http://127.0.0.1:38886/api/v1/counter/counter/get"
bb plugin dev --stop counter   # stop watching; the row keeps its last build

Where things go (Reference §1.8, §1.13): a path: install loads <dir>/dist in place, so there is no copy in the store. The install appends one insert { id: counter, name: "@bb-local/counter" } to the user layer ~/.bb/composition.yaml. Your data lives under ~/.bb/plugins/data/counter/: data.db for openDatabase, logs/<yyyymmdd>.jsonl for ctx.log. bb plugin remove counter deletes the row and keeps that data; --purge deletes it too.

7. Read the tree bb booted

bb composition dump prints the layers (base, user, patches), every row with its origin, status, and generation, and the arbitration result for contested services and slots. --row counter narrows it; --json gives the same as data.

bb composition dump --row counter
bb plugin show counter --json | jq '.status, .tiers'

bb plugin show is the one command that computes the stale tier state: a path: install whose source is newer than meta.builtAt (Reference §1.9). If you edited a file while bb plugin dev was not running, this is how you find out.

8. Read the status

PluginStatus has thirteen values (Reference §1.9). All thirteen, with what to do about each:

Status What it means What to do
missing the row names a package that is not installed bb plugin install or fix the row's name
incompatible manifest invalid, engines.bb unmet, or a referenced file escapes the package root read detail; fix the manifest; rebuild
needs-update sdkMajor/uiMajor mismatch; other tiers may still load rebuild against the current SDK
disabled row.disabled is true bb plugin enable <id>
replaced another enabled row forks this plugin with replaces bb plugin revert <forkId>
waiting a requires id is unbound, or the row is in a requires cycle; detail reads needs <id> <range> (have <version|none>) install or enable the provider
activating activate() is running, within the 30 s budget wait
running healthy; registrations published nothing
degraded serving, with recorded problems: a failed reload with the old generation still live, a registration conflict, a contested noun, CLI word, or tool name, a config problem bb plugin show <id> lists each problem; the rest of the plugin keeps serving (D1, D8)
needs-configuration a required setting has no value (checked before activate runs) or the plugin called ctx.status.needsConfiguration() / threw NeedsConfigurationError set the value in Settings or bb preferences; the row re-checks on a change to one of its own settings keys (D8)
failed activate threw or timed out, a digest or file check failed, or the worker crashed bb plugin logs <id>; fix; bb plugin reload <id>
disposing / disposed unloading; the row stays in the tree until the next resolve nothing

A waiting row is not an error: it means you declared requires on a service nobody provides yet. A degraded row is serving; read the problems before you decide whether they matter.

9. Enable, disable, reload, logs

bb plugin disable counter        # row.disabled = true; the generation is disposed
bb plugin enable counter         # back to activating → running
bb plugin reload counter         # a new generation; activate() runs again
bb plugin logs counter --follow  # JSONL lines: time, level, message, fields

disable/enable are sugar for composition.setRow { id, disabled }. Logs come from ctx.log.*, rotate at 8 MiB × 5, and are read by byte offset every second with --follow. A settings save never reloads a plugin; read settings per call with step.get() as the server above does.

10. Test before you ship

createTestPlugin runs the real loader against an in-memory database; expectDerivedSurfaces checks that every method derives a route, a CLI word, and docs, and that renderText handles a sample.

// src/contracts.test.ts
import { createTestPlugin, expectDerivedSurfaces } from "@get-bb/plugin-sdk/testing";
import { expect, it } from "vitest";
import { counter } from "./contracts.js";
import server from "./server.js";

it("derives every surface and counts", async () => {
  expectDerivedSurfaces(counter, { get: { input: {}, output: { value: 0 } }, bump: { input: { by: 1 }, output: { value: 1 } } });
  const plugin = await createTestPlugin({
    manifest: { id: "counter", provides: { "counter/counter": { version: "1.0.0" } }, contributes: { database: true } },
    server, contracts: [counter],
  });
  const svc = await plugin.inject(counter);
  expect(await svc.bump({ by: 2 })).toEqual({ value: 2 });
  expect(plugin.status().status).toBe("running");
  await plugin.dispose();
});

11. Pick the install path for your case

  • Private plugin for the team. Commit the source; each teammate runs pnpm install && bb plugin build . && bb plugin install path:$PWD (positional added at dc07292bf, Reference §1.14; Appendix B predates it). To pin a build, bb plugin pack . writes a deterministic tarball; host it over https: and install with bb plugin install url:https://…/counter-0.1.0.tgz. bb plugin plan <source> shows what would resolve without unpacking.
  • Try a plugin from a git checkout. git clone, pnpm install, bb plugin dev .. Done: bb plugin dev --stop <id>, then bb plugin remove <id>.
  • Ship to npm. Rename the package out of @bb-local/ (for example @acme/bb-plugin-counter), keep dist and src in files, build, bb plugin pack . to see what ships (.env* and .npmrc never do), npm publish. Users run bb plugin install npm:@acme/bb-plugin-counter, which places the plugin in a worker (Reference §1.10); that is why the server wraps its handlers in withDefaults (D7). bb plugin update <id> and bb plugin rollback <id> flip the version pointer.

What happens at runtime

From the two methods bb derives GET /api/v1/counter/counter/get, POST /api/v1/counter/counter/bump, bb counter get and bb counter bump [--by <integer>] with --json, help and exit codes, sdk.plugins.counter.counter.bump({ by }), a reference entry under bb guide reference counter, and the command counter/counter.bump on the bus (Reference §8.1). The manifest's settings.step gives you a Settings form row and bb preferences; in code the same key is ctx.preferences.define(STEP_KEY, …), and bump reads it when the caller omits by.

At boot the loader resolves the composition (base → user → patches), reads each artifact, classifies it (Reference §1.4), places it in-process (bundled and path:) or in a worker (npm:, url:), and activates rows in composition order. Your activate has 30 s; registrations become visible once their promise settles, so await ctx.provide(...) before anything that depends on it.

Pitfalls

  • bb plugin new and bb plugin types are not built (Reference §0.6, §1.7); the cli tier (defineOfflineHandlers) is not built either (Appendix B). Write the skeleton by hand and emit .d.ts with tsc -p tsconfig.types.json.
  • contributes.cli.commands feeds only the plan-time word-collision check; the CLI tree mounts from each service's cli.group (Reference §1.3). Keep the two equal so the check protects you.
  • Migrations are an append-only ledger keyed by index; write CREATE TABLE, not IF NOT EXISTS, and never edit an applied statement (conflict) (D13).
  • The composition file is not watched (D8). After editing ~/.bb/composition.yaml by hand, run bb plugin reload <id> or restart the server.
  • engines.bb, peer["@bb/ui"], and the published ranges for the SDK and @bb/ui are open questions (Reference §0.6); the defaults are *.
  • Worker placement has gaps: declared .default()/.transform() do not reach a handler without withDefaults, and a kind: "custom" method is refused in a worker (D7). Test both placements with createTestPlugin({ isolation: "worker" }).

See also

  • Reference §1.2§1.4 (manifest keys and validation order), §1.7 (build outputs), §1.8 (install sources), §1.9 (status), §1.11 (composition), §1.13 (storage), §1.14 (the bb plugin and bb composition CLI).
  • Reference §9.2 (createTestPlugin), §9.6 (expectDerivedSurfaces).
  • Introduction §2 for the full counter with the app tier; next/examples/plugins/hello-slot for a three-tier package with tests. Next page: Services.