Services: one definition, every surface

A service is the unit of work in a plugin. You define it once with defineService and method; bb derives the HTTP route, the CLI word, the typed SDK call, the agent tool, the docs, and the command-bus entry from that definition. This page builds tickets, a small issue tracker, and uses each method kind for what it is good at: a query and two mutations for the data, a stream for a live feed, and a custom method as a GitHub webhook that spawns a thread through another plugin's service.

Use this when

  • Expose my data to agents and humans with one definition. One method() gives you bb ticket list, GET /api/v1/tickets/tickets/list, and sdk.plugins.tickets.tickets.list({}); the next page turns the same method into a tool.
  • Receive webhooks. A kind: "custom" method with auth: "none" is a raw HTTP endpoint under your plugin's route prefix, with the body and headers in your hands.
  • Call another plugin's service. requires in the manifest plus ctx.inject(contract, range) gives you a typed handle on threads/threads without importing its code.
  • Stream a feed. A stream method is SSE over HTTP, NDJSON on the CLI, and for await in the SDK.

What you build

One service with five methods, provided by one server tier that keeps tickets in the plugin's SQLite file and opens a triage thread for every GitHub issue that arrives on the webhook.

Files: package.json, src/contracts.ts, src/server.ts.

Steps

1. Declare the edges in the manifest

The webhook calls threads/threads.spawn, so the manifest names that service under requires. The row stays waiting until a provider is bound, and ctx.inject must use the same range.

// package.json — the bb block and dependencies; exports, files, and scripts as on the Getting started page
{
  "name": "@bb-local/tickets",
  "bb": {
    "id": "tickets",
    "name": "Tickets",
    "description": "A small issue tracker that agents and humans share.",
    "server": "./src/server.ts",
    "contracts": "./src/contracts.ts",
    "provides": { "tickets/tickets": { "version": "1.0.0" } },
    "requires": { "threads/threads": "^1.0.0" },
    "contributes": {
      "database": true,
      "cli": { "commands": ["ticket"] },
      "settings": {
        "project": { "type": "project", "label": "Project for webhook threads", "required": true },
        "webhookSecret": { "type": "secret", "label": "GitHub webhook secret" }
      }
    }
  },
  "dependencies": { "@get-bb/plugin-sdk": "1.0.0-next.0", "@get-bb/plugin-threads": "^1.0.0", "zod": "4.3.6" }
}

requires is a hard edge: activation waits for the provider and re-runs when it rebinds. uses is the soft edge: activation does not wait, and ctx.tryInject answers null while nobody provides the id. An id may not appear in both (Reference §1.2, §3.1). The project setting is required, so the row shows needs-configuration until someone picks a project. The secret setting holds the reference plugin:tickets/webhookSecret; bb secret put plugin:tickets/webhookSecret writes the value.

2. Define the service

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

export const PROJECT_KEY = "tickets/project";
export const ticketSchema = z.strictObject({ id: z.number().int(), title: z.string(), body: z.string(), status: z.enum(["open", "closed"]) });
export type Ticket = z.infer<typeof ticketSchema>;
export const ticketLine = (t: Ticket): string => `#${t.id}  ${t.status.padEnd(6)}  ${t.title}`;

export const tickets = defineService({
  id: "tickets/tickets",
  version: "1.0.0",
  summary: "A small issue tracker",
  cli: { group: ["ticket"] },
  methods: {
    list: method({
      summary: "List tickets",
      input: z.strictObject({ status: z.enum(["open", "closed", "all"]).default("open").describe("Which tickets"), limit: z.number().int().min(1).max(500).default(50) }),
      output: z.array(ticketSchema),
      cli: { fields: { status: { alias: "s" } }, examples: [{ argv: "bb ticket list -s all", note: "every ticket" }] },
      renderText: (rows) => (rows.length === 0 ? "(no tickets)" : rows.map(ticketLine).join("\n")),
    }),
    create: method({
      kind: "mutation",
      summary: "Open a ticket",
      input: z.strictObject({ title: z.string().min(1).max(120).describe("One line"), body: z.string().default("").describe("Markdown body") }),
      output: ticketSchema,
      cli: { fields: { title: { positional: 0 }, body: { alias: "b" } } },
      renderText: ticketLine,
    }),
    close: method({
      kind: "mutation",
      summary: "Close a ticket",
      input: z.strictObject({ id: z.number().int().describe("Ticket number") }),
      output: ticketSchema,
      cli: { fields: { id: { positional: 0 } } },
      errors: { "tickets/already_closed": { status: 409, summary: "The ticket is already closed" } },
      renderText: ticketLine,
    }),
    follow: method({
      kind: "stream",
      summary: "Stream tickets as they are opened",
      input: z.strictObject({ after: z.number().int().min(0).default(0).describe("Start after this ticket number"), limit: z.number().int().min(1).max(1000).default(100) }),
      output: ticketSchema,
      renderText: ticketLine,
    }),
    webhook: method({
      kind: "custom",
      summary: "GitHub issues webhook: open a ticket and spawn a triage thread",
      input: z.strictObject({}),
      output: z.strictObject({ ticketId: z.number().int(), threadId: z.string() }),
      auth: "none",
      cli: { hidden: true },
    }),
  },
});

3. Know what each kind buys you

kind HTTP Handler Command bus CLI
query (default) GET, input as query params (input, call) => Promise<O> no word
mutation POST, JSON body (input, call) => Promise<O> tickets/tickets.create: actor stamped, before/after hooks, 60 s deadline word; y/N when destructive
stream GET + Accept: text/event-stream (input, call) => AsyncIterable<O> no word; NDJSON with --json
custom any verb, owns sub-paths { httpSerializable(req, call) } no hidden here

bb applies no rule to a query's side effects; a query bypasses the bus, so it has no command name, no interceptors, and no deadline. Choose mutation when you want those (Reference §2.2, MethodKind).

4. Keep inputs strict, fill defaults once, declare errors

Every input root is z.strictObject; an unknown key is invalid_input (400, issues[0].path = [key]) before any handler runs (D4). A .default() is filled once by the container in-process, so list always sees status and limit. In a worker the defaults do not arrive on their own; withDefaults(def, handlers) re-validates every non-custom input through the contract's own schema at the plugin boundary, so handlers see one shape in both placements (Reference §3.3). The CLI shows defaults in help as (default: open) and never applies them itself.

A handler throws KernelError. Kernel codes are closed; your own codes are <pluginId>/<snake_code>, declared on the method with a 4xx/5xx status, or they are rewritten to plugin_error with data.undeclaredCode. A non-KernelError becomes plugin_error; the stack never crosses a boundary (Reference §2.8, §3.12).

Code HTTP CLI exit
invalid_input, unknown_method / not_found 400, 404 / 404 2 / 3
unauthenticated, service_unavailable, needs_configuration 401, 503 4
timeout / conflict, precondition / forbidden, vetoed 504 / 409 / 403 5 / 6 / 7
tickets/already_closed the declared 409 6 (400 → 2, 404 → 3, 409 → 6, 403 → 7, else 1)
anything else, plugin_error 500 1

--json prints the bare result on success and {ok: false, error} on failure (D6).

5. Provide the handlers

// src/server.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import { definePlugin, KernelError, NeedsConfigurationError, sleep, withDefaults, type PluginHttpResponse, type SqlResultRow } from "@get-bb/plugin-sdk";
import { threads } from "@get-bb/plugin-threads/contracts";
import { z } from "zod";
import { PROJECT_KEY, ticketSchema, tickets, type Ticket } from "./contracts.js";

const MIGRATIONS = ["CREATE TABLE tickets (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, body TEXT NOT NULL, status TEXT NOT NULL)"];
const SELECT = "SELECT id, title, body, status FROM tickets";
const ticketOf = (row: SqlResultRow | null | undefined): Ticket => {
  if (row === null || row === undefined) throw new KernelError({ code: "not_found", message: "no such ticket" });
  return ticketSchema.parse(row);
};
const issueEvent = z.object({ action: z.string(), issue: z.object({ title: z.string(), body: z.string().nullable() }) });
const safeJson = (raw: string): unknown => { try { return JSON.parse(raw); } catch { return null; } };
const json = (status: number, body: unknown): PluginHttpResponse =>
  ({ status, headers: { "content-type": "application/json" }, body: { kind: "text", text: JSON.stringify(body) } });
const fail = (status: number, code: string, message: string): PluginHttpResponse => json(status, { ok: false, error: { code, message } });
const signatureOk = (raw: string, header: string, secret: string): boolean => {
  const expected = `sha256=${createHmac("sha256", secret).update(raw).digest("hex")}`;
  return header.length === expected.length && timingSafeEqual(Buffer.from(header), Buffer.from(expected));
};

export default definePlugin({
  async activate(ctx) {
    const db = await ctx.storage.openDatabase(MIGRATIONS);
    const project = ctx.preferences.define(PROJECT_KEY, z.string(), { scope: "profile", default: "" });
    const secretName = ctx.secrets.reference("webhookSecret");
    const secret = await ctx.secrets.resolve({ name: secretName }).then(
      (r) => r.value,
      (e: unknown) => { if (e instanceof KernelError && e.code === "not_found") return null; throw e; },
    );
    if (secret === null) throw new NeedsConfigurationError(`run: bb secret put ${secretName}`);
    const threadsSvc = await ctx.inject(threads, "^1.0.0");
    const open = async (title: string, body: string): Promise<Ticket> =>
      ticketOf((await db.run("INSERT INTO tickets (title, body, status) VALUES (?, ?, 'open') RETURNING id, title, body, status", [title, body])).rows[0]);

    await ctx.provide(tickets, withDefaults(tickets, {
      list: async ({ status, limit }) =>
        (await db.all(`${SELECT} WHERE ? = 'all' OR status = ? ORDER BY id LIMIT ?`, [status, status, limit])).map(ticketOf),
      create: ({ title, body }) => open(title, body),
      close: async ({ id }) => {
        const t = ticketOf(await db.get(`${SELECT} WHERE id = ?`, [id]));
        if (t.status === "closed") throw new KernelError({ code: "tickets/already_closed", message: `ticket #${id} is already closed`, data: { id } });
        await db.run("UPDATE tickets SET status = 'closed' WHERE id = ?", [id]);
        return { ...t, status: "closed" };
      },
      follow: async function* ({ after, limit }, call) {
        let cursor = after;
        for (let sent = 0; !call.signal.aborted && sent < limit; ) {
          const rows = await db.all(`${SELECT} WHERE id > ? ORDER BY id LIMIT ?`, [cursor, limit - sent]);
          if (rows.length === 0) { await sleep(1000, call.signal); continue; }
          for (const row of rows) { const t = ticketOf(row); cursor = t.id; sent += 1; yield t; }
        }
      },
      webhook: {
        httpSerializable: async (req) => {
          if (req.method !== "POST") return fail(405, "invalid_input", "POST only");
          const raw = req.body.kind === "text" ? req.body.text : Buffer.from(req.body.base64, "base64").toString("utf8");
          if (!signatureOk(raw, req.headers["x-hub-signature-256"] ?? "", secret)) return fail(401, "unauthenticated", "bad signature");
          const event = issueEvent.safeParse(safeJson(raw));
          if (!event.success) return fail(400, "invalid_input", "not a GitHub issues event");
          if (event.data.action !== "opened") return json(200, { ok: true, result: { ignored: event.data.action } });
          const ticket = await open(event.data.issue.title, event.data.issue.body ?? "");
          const thread = await threadsSvc.spawn({ projectId: await project.get(), title: `Ticket #${ticket.id}`, prompt: `Triage ticket #${ticket.id}: ${ticket.title}\n\n${ticket.body}` });
          ctx.log.info("webhook opened a thread", { ticketId: ticket.id, threadId: thread.id });
          return json(202, { ok: true, result: { ticketId: ticket.id, threadId: thread.id } });
        },
      },
    }));
  },
});

Three things to notice. The webhook reads the raw request: PluginHttpRequest is { method, path, query, headers, body } with body as { kind: "text", text } or { kind: "bytes", base64 }, and it answers with the same serializable pair (Reference §2.2). auth: "none" admits a caller with no credential, so the HMAC check is the only gate. The spawn call is a typed handle call: the container validates the input, fills the defaults you omitted, and dispatches the thread.create command with your plugin as the actor.

6. Render text and shape the CLI

renderText(value, ctx) is what the CLI prints without --json and what an agent reads as tool content. ctx is { width, color, tty, input } from Bb-Render: text; width=; color=; tty=, defaults {80, false, false}. It runs where the plugin code is loaded and never travels in contract.json; a method without one gets genericText (Reference §2.2, §8.4).

cli.fields has one row per input property; override only what you change. positional: n moves a field out of the flags (bb ticket close 12), flag renames it, alias is one letter (-s open), ambient: "projectId" fills it from BB_PROJECT_ID when omitted, hidden keeps it out of help, variadic takes the rest of argv into one array field, and examples are { argv, note } pairs printed in help. A group has at most two levels, and a method may set its own cli.group to mount under another plugin's word (Reference §8.4). The usage line for create reads bb ticket create <title> [--body <string>].

7. Consume the stream

bb ticket follow --after 0 --limit 3        # one rendered line per item; --timeout <seconds> is a client-side deadline
bb ticket follow --after 0 --json           # NDJSON, one ticket per line

curl -N -H "Accept: text/event-stream" …/tickets/tickets/follow?after=0 shows the wire: SSE with event: item / id: <seq> / data: {seq, value}, a : keep-alive comment first and every 15 s, then event: end {ok: true} or event: error {ok: false, error}; the deadline budget runs to the first frame. Set resumable: true to receive call.since from Last-Event-ID and gain --since <cursor> (Reference §8.2, §8.4). From a script: for await (const t of sdk.plugins.tickets.tickets.follow({ after: 0 })) ….

What happens at runtime

list is GET /api/v1/tickets/tickets/list?status=all, bb ticket list -s all, and sdk.plugins.tickets.tickets.list({ status: "all" }). create is POST …/create, bb ticket create "…" -b "…", .create({ title }), and the command tickets/tickets.create. follow is GET …/follow over SSE, bb ticket follow --json, and for await (… of .follow({})). webhook is ANY …/webhook with the raw pair; the SDK reaches it through sdk.raw(path, init) (Reference §8.1, §8.3).

expectDerivedSurfaces(tickets, samples) from @get-bb/plugin-sdk/testing asserts a route, JSON Schemas, a CLI word with usage and help, and a renderText that handles the sample for every method; call it once per service (Reference §9.6). A server test needs a provider for threads/threads because the manifest requires it: pass a fake through createTestPlugin({ with: [...] }) (Reference §9.2).

Every answer carries Bb-Call-Id, Bb-Generation, and Bb-Deadline. The boundary derives the actor from transport evidence (a loopback call is human:local; an agent shell's x-bb-context makes it agent:<threadId>); a caller cannot assert its own actor (Reference §8.2). auth: "operator" is the default and admits local, browser, token, and tool callers; "none" turns an unauthenticated refusal into the anonymous human:local row (D5).

Pitfalls

  • A custom method is refused in a worker's contract.json (D7). A plugin with a webhook must run in-process: bundled or path:. An npm: or url: install runs in a worker.
  • The listener is 127.0.0.1:38886 by default. A webhook from the internet needs a tunnel you operate; auth: "none" admits whatever reaches the port, so the signature check is your whole security.
  • inject(contract, range) must use the range the manifest declares; the handle binds one provider generation and answers stale_handle after a provider reload. Because threads/threads is under requires, activate re-runs on that reload and re-injects (Reference §3.3, §3.12).
  • Declared .default()/.transform() do not reach a worker handler without withDefaults (D7). Wrap every provide.
  • Bodies are capped at 1 MiB (413) on every route, custom ones included (Reference §2.2). expose.cli is not enforced over HTTP (D5); expose.sdk/expose.ui are X-BB-Client.kind gates, not security. Two plugins claiming the same CLI word: the later claimant is dropped with a warning: line and its row is degraded (D6, D8).

See also

  • Reference §2.2 (defineService, method, every MethodDef field and the invalid_contract list), §2.3 (handles and handlers), §2.8 (errors and the envelope), §3.3 (provide, inject, watch, withDefaults), §3.7 (preferences and secrets).
  • Reference §8.2 (HTTP, auth rows, code → status → exit), §8.3 (SDK), §8.4 (CLI flags and output); §9.2 (createTestPlugin with with), §9.6 (expectDerivedSurfaces).
  • Next page: Agent tools and agent configuration, which turns create into a tool.