Getting started: a plugin from an empty directory

This page takes you from an empty directory to a running bb 2.0 plugin. You will create a counter service and add a side panel to the app. You will build the plugin, start the development loop, and inspect the active generation.

Use this when

  • Start a plugin by hand. You want to understand each required file before you use a template.
  • Try a local plugin. You want bb to build, install, watch, and reload a source directory.
  • Add a server service. You want other plugins to use one typed, versioned capability.
  • Add a first app surface. You want to add UI through a contract that its owner declared.

What you build

The package has two runtime artifacts and one contracts module. The app artifact provides a side panel. The server artifact provides a counter service.

acme-counter/
├── package.json
├── bb.plugin.jsonc
├── tsconfig.json
└── src/
    ├── app.tsx
    ├── contracts.ts
    └── server.ts

The build writes dist/. It also generates contract.json from the manifest and the contracts module.

Steps

1. Create the package

Create an empty directory and enter it.

mkdir acme-counter
cd acme-counter

Add this package file. The package exports the contracts module for consumers.

// package.json
{
  "name": "@acme/bb-counter",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "files": ["dist", "src", "bb.plugin.jsonc"],
  "scripts": {
    "build": "bb plugin build .",
    "dev": "bb plugin dev .",
    "types": "bb plugin types ."
  },
  "exports": {
    "./contracts": {
      "types": "./dist/types/contracts.d.ts",
      "default": "./dist/contracts.mjs"
    }
  },
  "dependencies": {
    "@get-bb/plugin": "^2.0.0"
  },
  "peerDependencies": {
    "react": "^19.0.0"
  },
  "devDependencies": {
    "@types/react": "^19.0.0",
    "typescript": "^5.8.0"
  }
}

Add a small TypeScript configuration.

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "target": "ES2023",
    "lib": ["ES2023", "DOM"],
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "jsx": "react-jsx",
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "noEmit": true
  },
  "include": ["src"]
}

2. Write the 2.0 manifest

The kernel reads bb.plugin.jsonc before it runs plugin code. The manifest gives the complete static contract graph.

// bb.plugin.jsonc
{
  "$schema": "https://getbb.app/schemas/plugin-v2.schema.json",
  "schemaVersion": 2,
  "id": "acme.counter",
  "version": "0.1.0",
  "name": "Counter",
  "description": "Provides a small counter service and a thread side panel.",
  "category": "developer",
  "engines": {
    "bb": "^2.0.0",
    "sdk": "^2.0.0"
  },

  "claims": [
    { "service": "acme.counter.service", "version": "^1.0.0" },
    { "surface": "bb.thread-ui.sidePanels", "version": "^1.0.0", "order": 50 }
  ],

  "surfaces": [],
  "services": [
    {
      "id": "acme.counter.service",
      "version": "1.0.0",
      "kind": "single",
      "replaceable": true,
      "defaultProvider": "acme.counter",
      "contract": "./src/contracts.ts#counterService",
      "stability": "stable"
    }
  ],

  "requires": [],
  "optional": [],
  "watched": [],

  "exports": {
    "./contracts": "./dist/contracts.mjs"
  },
  "artifacts": {
    "app": "./dist/app.mjs",
    "server": "./dist/server.mjs"
  }
}

This plugin owns the acme.counter.* namespace. Contract IDs use dots. The service has the single kind, so one provider is active. The service is replaceable, so each compatible claimant joins the global winner picker. The default provider keeps a new installation usable without a user choice.

The side panel contract belongs to bb.thread-ui. The counter plugin claims that contract but does not declare it. The owner defines its props and its list behavior.

3. Define the service token

Keep types, tokens, and declarations in the contracts module. Do not put handlers or other runtime effects in this file.

// src/contracts.ts
import { defineService } from "@get-bb/plugin";

export interface CounterService {
  get(): Promise<number>;
  increment(by?: number): Promise<number>;
}

export const counterService = defineService<CounterService>(
  "acme.counter.service",
  "1.0.0",
);

The token carries TypeScript types, a dot-form ID, and a version. The loader matches the ID and a semantic version range. It does not use JavaScript object identity.

4. Add the server factory

The server artifact exports one typed factory. The factory receives the server API object.

// src/server.ts
import { defineServerPlugin } from "@get-bb/plugin/server";
import { counterService, type CounterService } from "./contracts.js";

export default defineServerPlugin(async (api) => {
  const values = api.storage.kv();
  let value = (await values.get<number>("value")) ?? 0;

  const counter: CounterService = {
    async get() {
      return value;
    },

    async increment(by = 1) {
      value += by;
      await values.set("value", value);
      api.log.info("Counter changed", { value, by });
      return value;
    },
  };

  api.services.provide(counterService, counter);
});

api.storage is the plugin-bound bb.storage kernel port. The kernel also supplies bb.secrets, bb.preferences, bb.realtime, bb.http, bb.rpc, and bb.plugins ports.

The service registration has automatic cleanup. The factory can return a disposer when it creates a resource outside the API. This factory does not need one.

5. Provide the first surface

The app artifact also exports one typed factory. This example provides one item for the bb.thread-ui.sidePanels list surface.

// src/app.tsx
import { definePlugin } from "@get-bb/plugin/app";

export default definePlugin((api) => {
  api.surfaces.provide("bb.thread-ui.sidePanels", {
    key: "acme.counter",
    title: "Counter",
    component: ({ threadId }) => (
      <section aria-label="Counter">
        <h2>Counter</h2>
        <p>The counter plugin is active for thread {threadId}.</p>
      </section>
    ),
  });
});

The manifest claim and the runtime registration must agree. The build rejects a missing claim or a missing registration. The owner gives this surface the list kind, so all ordered items can appear.

A replaceable single surface works differently. Its selected component receives the typed default component as Original. The backend service never receives an Original handle.

6. Install dependencies and build

Install the package dependencies. Then check the types and build the verified artifacts.

pnpm install
pnpm run types
bb plugin build .

The build writes dist/ only after all checks pass. The important outputs are these files.

dist/
├── app.mjs
├── server.mjs
├── contracts.mjs
├── bb.plugin.json
├── contract.json
└── artifact.json

bb.plugin.json is the strict manifest. contract.json contains the full service and surface contract data. artifact.json contains file digests and build facts. Authors do not edit these generated files.

7. Start the development loop

Start a bb server first. Then run this command from the plugin directory.

bb plugin dev .

The command builds the package and installs it as a path: source. It watches the source and builds a new candidate after each change. It reloads the plugin only after verification succeeds.

Open bb and select a thread. The Counter item now appears with the other thread side panels.

8. Inspect the active plugin

Use show for the current plugin record. Use JSON output when a script needs the result.

bb plugin show acme.counter
bb plugin show acme.counter --json
bb plugin winners acme.counter.service

The result identifies the source, version, generation, artifacts, claims, and current state. The winners command shows acme.counter as the default service provider.

If activation fails, use these commands.

bb plugin logs acme.counter
bb plugin doctor acme.counter

The loader keeps the previous generation active when a new factory fails. It discards every staged registration from the failed candidate.

9. Stop or remove the local plugin

Press Ctrl+C to stop the development watcher. The installed path: record stays available.

Remove the record when you no longer need the plugin.

bb plugin remove acme.counter

The remove command keeps plugin data by default. Use --purge-data only when you also want to remove the stored counter value.

What happens at runtime

The kernel reads the strict manifest and contract.json before it imports a runtime artifact. It verifies the graph, resolves service winners, and orders list claims. It then calls the server factory with a typed API object.

The kernel stages the counter service registration during the factory call. It commits the complete generation as one unit. The app loader then mounts the selected surface items.

A service consumer imports counterService from @acme/bb-counter/contracts. It must also declare a matching edge in requires, optional, or watched. A required consumer can then call await api.services.use(counterService).

Pitfalls

  • Keep bb.plugin.jsonc separate from package.json.
  • Use dot-form contract IDs such as acme.counter.service.
  • Declare every service or surface before you provide it at runtime.
  • Keep runtime effects out of src/contracts.ts.
  • Import each factory from its tier entry in @get-bb/plugin.
  • Do not edit contract.json; the build generates it.

See also

  • Kernel explains manifests, verification, ports, and the bb plugin commands.
  • Authoring a plugin explains factories, contracts, claims, exports, and tests.
  • The next page explains services and service edges in more detail.