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 youbb ticket list,GET /api/v1/tickets/tickets/list, andsdk.plugins.tickets.tickets.list({}); the next page turns the same method into a tool. - Receive webhooks. A
kind: "custom"method withauth: "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.
requiresin the manifest plusctx.inject(contract, range)gives you a typed handle onthreads/threadswithout importing its code. - Stream a feed. A
streammethod is SSE over HTTP, NDJSON on the CLI, andfor awaitin 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 linecurl -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
custommethod is refused in a worker'scontract.json(D7). A plugin with a webhook must run in-process: bundled orpath:. Annpm:orurl:install runs in a worker. - The listener is
127.0.0.1:38886by 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 answersstale_handleafter a provider reload. Becausethreads/threadsis underrequires,activatere-runs on that reload and re-injects (Reference §3.3, §3.12).- Declared
.default()/.transform()do not reach a worker handler withoutwithDefaults(D7). Wrap everyprovide. - Bodies are capped at 1 MiB (413) on every route, custom ones included (Reference §2.2).
expose.cliis not enforced over HTTP (D5);expose.sdk/expose.uiareX-BB-Client.kindgates, not security. Two plugins claiming the same CLI word: the later claimant is dropped with awarning:line and its row isdegraded(D6, D8).
See also
- Reference §2.2 (
defineService,method, everyMethodDeffield and theinvalid_contractlist), §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 (
createTestPluginwithwith), §9.6 (expectDerivedSurfaces). - Next page: Agent tools and agent configuration, which turns
createinto a tool.