Agent tools and agent configuration

An agent tool is a service method with expose: { tool: true }; bb builds the tool from the method's schema and runs each call through the same handler the CLI and HTTP use. Agent configuration is the other direction: your plugin tells every agent session which tools it gets, what instructions to read, and which environment to run in, through the threads/agent-config service. This page turns the tickets service from the previous page into a tool with its own timeline card, injects rules into every agent, turns the tool off for manager threads, and ships a skill.

Use this when

  • Let agents file a ticket in my tracker. One expose flag plus a presentation; the agent gets tickets_tickets_create with a JSON Schema, a label, and an icon.
  • Inject project rules into every agent. ctx.agents.contributeInstructions(text) appends to the system prompt of every thread, or of one project.
  • Turn a tool off for manager threads. A resolver decides per turn which tools a thread sees; here top-level threads lose tickets_tickets_create and their child threads keep it.
  • Ship a skill with my plugin. contributes.guide puts your prose in the generated bb skill today; contributes.skills ships a SKILL.md directory in the artifact.

What you build

The tickets plugin gains a tool, an app tier that renders the tool's result in the timeline, an agent-config contribution with a per-thread tool resolver, a guide fragment, and a skill directory.

Files: package.json, src/contracts.ts, src/server.ts, src/app.tsx, docs/guide.md, skills/tickets/SKILL.md.

Steps

1. Expose the method as a tool

expose.tool requires tool.presentation; invalid_contract is thrown at definition time without it. label.pending and label.completed are what the timeline shows while the call runs and after; icon is an @bb/ui icon name; intent picks the row painter (generic | terminal | diff | search | read | list | web | image). instructions (at most 4096 bytes) is appended to the session instructions while the tool is active. ui.renderer names the component that paints the result (step 3).

// src/contracts.ts — the create method, now a tool
create: method({
  kind: "mutation",
  summary: "Open a ticket",
  description: "Creates a ticket in the team tracker and returns it.",
  input: z.strictObject({
    title: z.string().min(1).max(120).describe("One line"),
    body: z.string().default("").describe("Markdown body: what happened, how to reproduce, what you expected"),
  }),
  output: ticketSchema,
  expose: { tool: true },
  cli: { fields: { title: { positional: 0 }, body: { alias: "b" } } },
  tool: {
    presentation: { label: { pending: "Filing ticket…", completed: "Filed ticket" }, icon: "ListTodo", intent: "generic" },
    instructions: "File a ticket when the user asks to track a bug or a task. Put the reproduction steps in `body`; never file duplicates without checking `tickets_tickets_list` first.",
    ui: { renderer: "tickets/ticket-card" },
  },
  renderText: ticketLine,
}),

The tool name defaults to <plugin_id>_<service>_<method> in snake_case: tickets_tickets_create. Set tool.name to choose another; it must match [a-zA-Z0-9_-]+, stay under 64 characters, and be unique across the composition, or the row is degraded (Reference §8.5). Expose list the same way so the instruction above has something to point at. custom methods cannot be tools.

2. Know what a tool call does

toolSpecOf builds one DynamicTool per exposed method: description is tool.description, else summary + description, plus "Destructive: confirm with the user first." when destructive; inputSchema is the input JSON Schema with ambient fields kept but made optional. runTool runs the call as actor { kind: "agent", id: threadId } with caller.via: "tool" and render: "text", and fills ambient fields the model omitted (threadId, projectId, environmentId, hostId, cwd) from the session. A mutation still dispatches its command, so before interceptors see the agent actor and can veto. The result is a ToolResult (Reference §8.5):

Field Value
success false on any envelope error; the error is one text block <code>: <message>
content [{ type: "text", text }] from renderText, else compact JSON; a stream tool collects its items, one line each
ui { renderer: "tickets/ticket-card", payload: <the ticket> } when tool.ui is declared, else null

A destructive tool still goes through the thread's permission mode; a blocking tool may park on an interaction up to blocking.maxMs. Both the tool and the CLI word stay callable; the per-session skill text replaces the CLI line of a method whose tool is active with "use the tickets_tickets_create tool".

3. Render the result in the timeline

The timeline is a keyed slot, timeline.row, with a five-rung key ladder; the first rung is tool-ui:<pluginId>/<renderer>, tried only for a tool row whose payload.result.ui is set (Reference §5 preamble, §5.8). Register a component under that key. Use app.slots.inject rather than register, because ui-timeline-view declares the slot and this plugin does not requires it: the thunk runs when the declaration exists and is dropped when it vanishes (Reference §4.2).

// src/app.tsx
import { definePluginApp } from "@get-bb/plugin-sdk/app";
import type { SlotComponentProps } from "@get-bb/plugin-sdk/app";
import { Badge } from "@bb/ui";
import { z } from "zod";
import { ticketSchema } from "./contracts.js";

// parse the row at the boundary: the props of an unmerged slot name are untyped
const toolRow = z.object({
  kind: z.literal("tool"),
  payload: z.object({ result: z.object({ ui: z.object({ payload: ticketSchema }) }) }),
});

const cardOptions = { name: "timeline.row", kind: "keyed", scope: "thread", key: "tool-ui:tickets/ticket-card" } as const;
type CardProps = SlotComponentProps<"timeline.row", typeof cardOptions>; // the literal options fix kind and scope, so `Original` is typed

function TicketCard(props: CardProps) {
  const parsed = toolRow.safeParse(props.row);
  const Original = props.Original;
  if (!parsed.success) return <Original {...props} />;
  const t = parsed.data.payload.result.ui.payload;
  return (
    <div className="flex items-center gap-2">
      <Badge>#{t.id}</Badge>
      <span>{t.title}</span>
      <Badge>{t.status}</Badge>
    </div>
  );
}

export default definePluginApp({
  setup(app) {
    app.slots.inject("timeline.row", (slots) => {
      slots.register(cardOptions, TicketCard);
    });
  },
});

Add the tier to the manifest: "app": "./src/app.tsx" and "contributes": { "slots": ["timeline.row"] }; the build cross-checks contributes.slots against every name the app registers into or injects (D9). Add "@bb/ui": "*" to dependencies and "react": "^19.0.0" to peerDependencies. Keep setup synchronous. The timeline falls back to content when the renderer is absent, so a text-only plugin still reads well.

4. Configure agents from the server tier

ctx.agents.configure is one live contribution to threads/agent-config for as long as the plugin runs. Declare the soft edge in the manifest, "uses": { "threads/agent-config": "^1.0.0" }; that is what lets a late-installed threads be seen (Reference §3.8). The call contributes when a provider is bound, re-contributes after a threads reload, resolves at once when no provider is bound, and returns a disposer that revokes.

// src/server.ts — inside activate, after ctx.provide(...)
const rules = ctx.preferences.define(RULES_KEY, z.string(), { scope: "profile", default: "" });

await ctx.agents.configure({
  tools: { resolver: { service: "tickets/tickets", method: "toolsFor" } },
  env: { BB_TICKETS_ENABLED: "1" },
});

// "inject project rules into every agent": the text lives in a settings field the user edits
let stopRules = await ctx.agents.contributeInstructions(await rules.get());
await rules.watch((text) => {
  void Promise.resolve(stopRules()).then(async () => { stopRules = await ctx.agents.contributeInstructions(text); });
});

AgentConfiguration is { scope?, tools?, instructions?, skills?, env?, priority? }; omission means "nothing for that field". scope is { kind: "all" } (default), { kind: "project", projectId }, or { kind: "thread", threadId }. tools is { include, exclude } over tool names. instructions is { text, mode: "append" | "prepend" | "replace" } with text at most 32,768 characters; contributeInstructions(text, scope?) is the append shortcut. env admits BB_* names only and rejects configure() with invalid_input otherwise; the host applies them at bridge spawn. skills is SkillRef[], { rootId, path }. Add RULES_KEY = "tickets/rules" to contracts.ts and a rules: { "type": "text", "label": "Rules for agents" } row under contributes.settings.

5. Select tools per thread with a resolver

Any of tools, instructions, skills, env may be a ResolverRef: { resolver: { service, method } }, a query on your own service that receives the agent-config context once per turn and returns the field's literal shape. The threads plugin time-boxes it at 2 s and drops the field for that turn when it fails or returns the wrong shape; it never fails the turn. The context the threads plugin sends is { threadId, projectId, environmentId, hostId, providerId, turnId, origin: { kind: "new" | "fork", actor }, parentThreadId }. This example treats a top-level thread (parentThreadId === null) as a manager that delegates, and lets only its child threads file tickets.

// src/contracts.ts — an internal query the resolver ref names; no HTTP, CLI, SDK, tool, or UI surface
const actorSchema = z.strictObject({ kind: z.enum(["human", "agent", "plugin", "system"]), id: z.string() });
export const agentContextSchema = z.strictObject({
  threadId: z.string(),
  projectId: z.string(),
  environmentId: z.string().nullable(),
  hostId: z.string().nullable(),
  providerId: z.string(),
  turnId: z.string(),
  origin: z.strictObject({ kind: z.enum(["new", "fork"]), actor: actorSchema }),
  parentThreadId: z.string().nullable(),
});
// add to `methods` of `tickets`:
toolsFor: method({
  summary: "Tool selection for one turn (resolver for threads/agent-config)",
  input: agentContextSchema,
  output: z.strictObject({ include: z.array(z.string()), exclude: z.array(z.string()) }),
  expose: { http: false, sdk: false, cli: false, ui: false },
}),
// src/server.ts — the handler
toolsFor: async ({ parentThreadId }) => ({
  include: [],
  exclude: parentThreadId === null ? ["tickets_tickets_create"] : [],
}),

The merge runs in composition order, then priority: every contribution's exclude removes names from the universe of exposed tools, include adds them back. A later row can re-include what you excluded; pin the order with priority when it matters.

6. Ship a skill

Two surfaces exist. contributes.guide (a Markdown file, "guide": "./docs/guide.md") is built: the guide plugin assembles <dataDir>/skills-generated/bb/SKILL.md from its preamble, every plugin's guide fragment in composition order, and the generated command index, and bb guide tickets renders the fragment offline (Reference §8.6). Write the fragment for an agent: when to file, what a good body looks like, which word to run.

contributes.skills (default ["skills"]) names directories of skills/<name>/SKILL.md that the build copies into the artifact (Reference §1.3, §1.7). A declared root must exist; [] opts out. Delivery to a provider goes through the skills plugin's catalog and the provider's skill roots, which are not part of the Stage 2 tree; ship the directory now so the artifact is ready, and rely on the guide fragment and tool.instructions for text agents read today.

<!-- skills/tickets/SKILL.md -->
---
name: tickets
description: File and track tickets in the team tracker with `bb ticket` or the tickets_tickets_create tool.
---
Run `bb ticket list` before filing. A good ticket has a one-line title and a body with reproduction steps.

7. Test the tool face

// fakeThreads: a TestPluginSpec that provides threads/threads, because the manifest `requires` it (Reference §9.2)
const plugin = await createTestPlugin({ manifest, server, contracts: [tickets], with: [fakeThreads] });
const tool = plugin.tool("tickets_tickets_create");
const result = await tool.execute({ title: "Login times out" }, { actor: { kind: "agent", id: "thr_test" } });
expect(result).toMatchObject({ id: 1, status: "open" });

plugin.tool(name) matches tool.name from the generated contract.json; opts.actor is required. expectDerivedSurfaces(tickets, samples) asserts the tool name is within limits and the presentation has both labels and an icon (Reference §9.2, §9.6).

What happens at runtime

The agent reads two texts. The generated bb skill lists every visible CLI word with its usage line and summary; while tickets_tickets_create is active its line reads "use the tickets_tickets_create tool" instead of bb ticket create <title> [--body <string>]. The session instructions carry, in order, every instructions contribution merged by mode, then the tool.instructions of each active tool. Per turn the threads plugin resolves the merged configuration: the tool universe minus every exclude plus every include, the instructions, the skills, and the BB_* env, with a provenance list naming which plugin and generation contributed each field.

When the model calls the tool, the bridge sends tool/call, runTool validates the input against the method schema, dispatches tickets/tickets.create as agent:<threadId>, renders the result with renderText, and returns { success, content, ui }. The timeline row for the call tries tool-ui:tickets/ticket-card, then tool:tickets_tickets_create, then the provider's and core tool painters, so your card wins when it is registered and the default tool row paints otherwise.

Pitfalls

  • tool.presentation.icon must be a core or extended @bb/ui name. A <pluginId>/<name> icon from contributes.icons.named has no renderer in the slice today (Reference §4.11).
  • tool.instructions over 4096 bytes is invalid_contract at definition time; instructions.text in configure over 32,768 characters is invalid_input.
  • agents.configure needs uses: { "threads/agent-config": "^1.0.0" } in the manifest. Without it the contribution is never made, and your activate does not re-run when threads appears (Reference §3.8, D19).
  • A resolver must be a query on the contributing plugin's own service; threads refuses another plugin's service with invalid_input. Its input root must be a strict object like every method's, so mirror the context fields exactly.
  • A resolver that throws, times out (2 s), or returns the wrong shape drops that field for the turn and logs agent-config.resolver_failed (agent-config.resolver_timeout for the 2 s case); the turn proceeds without your selection.
  • env names outside BB_* reject the whole configure() call before anything is contributed.
  • tool.ui payloads are JSON; parse them at the boundary in the renderer and fall back to Original, as above. The bridge item schema carries ui beside result, so keep the parse tolerant.
  • The 64-character tool-name ceiling is enforced only by expectDerivedSurfaces, not at definition or plan time (Reference §8.5). Call it in every suite.
  • SkillRef.rootId semantics and the skills/catalog service are outside this tree; do not promise skill delivery you have not seen an agent receive.

See also

  • Reference §2.2 (Expose, ToolMeta, ToolPresentation, RenderIntent), §8.5 (tool spec, runTool, ToolResult), §8.6 (docs and the generated skill).
  • Reference §3.8 (AgentsApi, AgentConfiguration, ToolSelection, ResolverRef), §4.2 (app.slots.inject), §5.8 (timeline.row and the key ladder), §4.11 (icon names).
  • Reference §9.2 (plugin.tool), §9.6 (expectDerivedSurfaces).
  • next/plugins/threads/src/contracts.ts (threads/agent-config, bb_spawn_thread with ui: { renderer }) for the first-party shapes this page mirrors.