Services and service edges

A service is a typed contract with one or more implementations. Plugins use services to share data and behavior across runtime tiers.

This page builds acme.thread-notes.service. The service stores one short note for each thread.

Use this when

  • Share behavior between plugins. Define one typed contract, provide it in one plugin, and use it from another plugin.
  • Follow the user's provider choice. A replaceable service resolves through the global winner picker.
  • Control startup after a dependency change. Choose a required, optional, or watched edge for each service dependency.
  • Use a bb domain service. Consume bb.threads, bb.workspace, bb.files, bb.vcs, or bb.providers through typed contracts.

What you build

The provider plugin declares and provides acme.thread-notes.service. It also uses three service edge types.

A second plugin requires the new service and reads a note. Both plugins use server factories.

1. Define the service contract

Put public types and tokens in a contracts module. Do not put handlers or runtime state in this module.

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

export interface ThreadNote {
  threadId: string;
  text: string;
  updatedAt: string;
}

export interface ThreadNotesService {
  get(input: { threadId: string }): Promise<ThreadNote | null>;
  set(input: { threadId: string; text: string }): Promise<ThreadNote>;
}

export const threadNotesService = defineService<ThreadNotesService>(
  "acme.thread-notes.service",
  "1.0",
);

defineService creates a typed, versioned token. The loader matches its dot-separated ID and semantic version range.

The loader does not use JavaScript object identity. Two packages can contain separate token copies and still resolve the same contract.

The build writes the public contract to dist/contract.json. The loader uses that file for claim and compatibility checks.

2. Declare the service and its edges

The manifest gives the kernel the full static graph. The kernel reads this graph before it runs plugin code.

// bb.plugin.jsonc
{
  "$schema": "https://getbb.app/schemas/plugin-v2.schema.json",
  "schemaVersion": 2,
  "id": "acme.thread-notes",
  "version": "2.0.0",
  "name": "Thread notes",
  "description": "Stores one short note for each thread.",
  "category": "productivity",
  "engines": { "bb": "^2.0.0", "sdk": "^2.0.0" },

  "claims": [
    { "service": "acme.thread-notes.service", "version": "^1" }
  ],

  "services": [
    {
      "id": "acme.thread-notes.service",
      "kind": "single",
      "version": "1.0.0",
      "contract": "./src/contracts.ts#threadNotesService",
      "replaceable": true,
      "defaultProvider": "acme.thread-notes",
      "stability": "stable"
    }
  ],

  "requires": [
    { "service": "bb.threads", "range": "^1" }
  ],
  "optional": [
    { "service": "bb.workspace", "range": "^1" }
  ],
  "watched": [
    { "service": "bb.providers", "range": "^1" }
  ],

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

The services entry declares the contract. The claims entry states that this plugin supplies one implementation.

The contract owner sets replaceable. A replaceable server service must name its default provider.

The three edge arrays define different lifecycle rules. A plugin cannot access a service without the matching edge.

3. Provide the implementation

The server artifact exports one factory. The loader gives the factory a typed api object for its generation.

// src/server.ts
import { defineServerPlugin } from "@get-bb/plugin/server";
import { bbThreads } from "@bb/threads/contracts";
import { bbWorkspace } from "@bb/workspace/contracts";
import { bbProviders } from "@bb/providers/contracts";
import {
  threadNotesService,
  type ThreadNote,
} from "./contracts.js";

export default defineServerPlugin(async (api) => {
  const threads = await api.services.use(bbThreads.server);
  const workspace = api.services.optional(bbWorkspace.server);
  const notes = new Map<string, ThreadNote>();

  api.services.watch(bbProviders.server, (change) => {
    api.log.info("Provider service changed.", {
      available: change.kind === "available",
    });
  });

  api.log.info("Workspace service state.", {
    available: workspace.current() !== undefined,
  });

  api.services.provide(threadNotesService, {
    async get({ threadId }) {
      await threads.records.get({ threadId });
      return notes.get(threadId) ?? null;
    },

    async set({ threadId, text }) {
      await threads.records.get({ threadId });
      const note = {
        threadId,
        text,
        updatedAt: new Date().toISOString(),
      };
      notes.set(threadId, note);
      return note;
    },
  });
});

api.services.provide fulfills the static claim. The loader stages this implementation until the factory completes successfully.

The loader commits all factory registrations as one generation. If the factory throws, the current generation stays active.

The API removes registrations and watchers when the generation stops. Return a disposer only for a resource outside the API.

The example uses an in-memory map for clarity. A production plugin can store notes through the bb.storage kernel port.

4. Use the service from another plugin

The consumer must declare a compatible edge before it calls use.

// bb.plugin.jsonc in acme.note-reader
{
  "$schema": "https://getbb.app/schemas/plugin-v2.schema.json",
  "schemaVersion": 2,
  "id": "acme.note-reader",
  "version": "2.0.0",
  "name": "Note reader",
  "description": "Reads thread notes.",
  "category": "productivity",
  "engines": { "bb": "^2.0.0", "sdk": "^2.0.0" },
  "requires": [
    { "service": "acme.thread-notes.service", "range": "^1" }
  ],
  "artifacts": {
    "server": "./dist/server.mjs"
  }
}
// src/server.ts in acme.note-reader
import { defineServerPlugin } from "@get-bb/plugin/server";
import { threadNotesService } from "@acme/bb-plugin-thread-notes/contracts";

export default defineServerPlugin(async (api) => {
  const notes = await api.services.use(threadNotesService);
  const note = await notes.get({ threadId: "thread_123" });

  api.log.info("Thread note read.", {
    found: note !== null,
  });
});

use returns the current live, in-process service object. Local server calls do not use hidden HTTP or RPC.

The loader rejects undeclared service access. It also rejects a version that does not satisfy the manifest range.

5. Choose the correct edge

Every service use needs one static edge. Choose the edge from the consumer's behavior during provider loss.

Edge Manifest array Access Provider loss or replacement
Required requires await api.services.use(token) The consumer waits at startup. The loader restarts required dependents.
Optional optional api.services.optional(token) The consumer stays active. The reference tracks the current provider.
Watched watched api.services.watch(token, listener) The consumer stays active. The listener receives binding changes.

An optional reference has two main operations.

const workspace = api.services.optional(bbWorkspace.server);

const current = workspace.current();
const later = await workspace.whenAvailable();

current() returns undefined when no compatible provider exists. whenAvailable() waits for the next compatible provider.

A watched listener receives explicit availability changes.

api.services.watch(bbProviders.server, (change) => {
  if (change.kind === "available") {
    api.log.info("Providers available.", {
      providerPluginId: change.providerPluginId,
      generation: change.generation,
    });
  }

  if (change.kind === "unavailable") {
    api.log.warn("Providers unavailable.");
  }
});

Use a required edge when the plugin cannot work without the service. Use an optional edge for a feature that can wait.

Use a watched edge when the plugin must react to each binding change. Optional and watched consumers do not restart.

6. Understand replaceable services

A replaceable service joins the global winner picker. The user selects one winner for all users and runtime generations.

The kernel uses the same four contract kinds for services and surfaces.

Kind Active implementations User control
single One implementation Select one winner when the owner allows replacement.
list All ready items Hide or reorder items.
keyed One implementation for each key Select one winner for each replaceable key.
chain Ordered wrappers around one base Enable or reorder members.

A non-replaceable single collision fails before activation. A non-replaceable keyed collision fails for that key.

The kernel does not use load order to select a winner. The owner declares the default provider for a replaceable server service.

A server service never receives Original. Only a winning single surface receives the typed Original component.

Use a chain service when the contract must support wrappers. Do not keep two stateful single providers active as wrappers.

7. Follow a winner change

The kernel changes a service winner as one controlled transaction.

  1. The kernel starts and verifies the candidate generation.
  2. The candidate stages all claimed implementations.
  3. The candidate reports service readiness.
  4. The kernel commits the candidate generation.
  5. The kernel stops new calls to the old winner.
  6. The kernel drains old calls to the deadline.
  7. The kernel changes the global winner record.
  8. The kernel restarts required dependents in graph order.

The restart follows required edges transitively. Each restarted factory gets fresh service handles.

Do not keep a service handle after its plugin generation stops. The old handle rejects new work after disposal.

Optional and watched consumers stay active. They observe unavailable and then the new compatible provider.

A failed candidate does not change the active winner. A failed active winner falls back to the declared default provider.

8. Use the bb domain services

First-party plugins provide domain services as ordinary replaceable contracts. A replacement can implement the same public contract.

Service Owner package Purpose
bb.threads @bb/threads Thread records, execution, timeline, events, drafts, and selection.
bb.workspace @bb/workspace Projects, environments, hosts, terminals, and routing.
bb.files @bb/files Workspace file operations, previews, uploads, and file watches.
bb.vcs @bb/vcs Generic repository status, heads, changed files, commits, history, and diffs.
bb.providers @bb/providers Provider discovery, models, health, usage, and bridge access.

Import each token and its data types from the owner's contracts export. Declare an edge to the service ID in bb.plugin.jsonc.

These contracts return domain data and behavior. They do not return React components or layout choices.

9. Keep kernel ports separate

The kernel provides fixed ports for bb.storage, bb.secrets, bb.preferences, bb.realtime, bb.http, bb.rpc, and bb.plugins.

The global winner picker does not contain these ports. The factory API binds each port to the plugin identity and generation.

Use api.storage, api.secrets, api.realtime, api.http, and api.rpc on the server tier. Use owner services for domain work.

10. Use contracts for choices

A module import selects exact code. It does not change after a winner change.

A service token selects the current provider. Required consumers restart after the provider changes.

Use a service contract for replaceable behavior. Use a module import for a fixed helper or component.