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-counterto 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 buildWhere 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, fieldsdisable/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 atdc07292bf, Reference §1.14; Appendix B predates it). To pin a build,bb plugin pack .writes a deterministic tarball; host it overhttps:and install withbb 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>, thenbb plugin remove <id>. - Ship to npm. Rename the package out of
@bb-local/(for example@acme/bb-plugin-counter), keepdistandsrcinfiles, build,bb plugin pack .to see what ships (.env*and.npmrcnever do),npm publish. Users runbb 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 inwithDefaults(D7).bb plugin update <id>andbb 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 newandbb plugin typesare not built (Reference §0.6, §1.7); theclitier (defineOfflineHandlers) is not built either (Appendix B). Write the skeleton by hand and emit.d.tswithtsc -p tsconfig.types.json.contributes.cli.commandsfeeds only the plan-time word-collision check; the CLI tree mounts from each service'scli.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, notIF NOT EXISTS, and never edit an applied statement (conflict) (D13). - The composition file is not watched (D8). After editing
~/.bb/composition.yamlby hand, runbb plugin reload <id>or restart the server. engines.bb,peer["@bb/ui"], and the published ranges for the SDK and@bb/uiare open questions (Reference §0.6); the defaults are*.- Worker placement has gaps: declared
.default()/.transform()do not reach a handler withoutwithDefaults, and akind: "custom"method is refused in a worker (D7). Test both placements withcreateTestPlugin({ 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 pluginandbb compositionCLI). - Reference §9.2 (
createTestPlugin), §9.6 (expectDerivedSurfaces). - Introduction §2 for the full
counterwith the app tier;next/examples/plugins/hello-slotfor a three-tier package with tests. Next page: Services.