Agent tools, tool-call policy, and AI services
The bb.agents plugin owns agent tools, tool-call policy, and small AI services. This page adds all three to one server plugin. The tool answers questions from a repository index. An interceptor removes known secrets before any selected tool receives input. An AI service supplies structured inference through a local model adapter.
Use this when
- Give an agent a typed action. Claim one
bb.agents.toolkey and add its behavior withapi.tools.add(). - Replace a tool implementation. Claim the same replaceable key and let the user select its winner.
- Apply policy to every tool call. Add a member to the ordered
bb.agents.toolCallschain. - Offer helper AI operations. Claim one
bb.agents.aikey for structured inference, voice transcription, or both.
What you build
The acme.repo-agents plugin adds the acme_repo_facts tool. It also adds one redaction interceptor and the acme.repo-ai inference service.
Files: bb.plugin.jsonc, src/server.ts, src/repository-backend.ts, src/server.test.ts, and src/test-artifacts.ts.
Steps
1. Declare the graph
Use bb.plugin.jsonc for all static claims and service edges. The keyed claims include the model tool name and AI service ID.
// bb.plugin.jsonc
{
"$schema": "https://getbb.app/schemas/plugin-v2.schema.json",
"schemaVersion": 2,
"id": "acme.repo-agents",
"version": "2.0.0",
"name": "Acme repository agents",
"description": "Repository tools, policy, and helper AI services.",
"engines": { "bb": "^2.0.0", "sdk": "^2.0.0" },
"claims": [
{
"service": "bb.agents.tool",
"version": "1.0.0",
"key": "acme_repo_facts"
},
{
"service": "bb.agents.toolCalls",
"version": "1.0.0"
},
{
"service": "bb.agents.ai",
"version": "1.0.0",
"key": "acme.repo-ai"
}
],
"requires": [
{ "service": "bb.agents.tool", "range": "^1" },
{ "service": "bb.agents.toolCalls", "range": "^1" },
{ "service": "bb.agents.ai", "range": "^1" }
],
"artifacts": {
"server": "./dist/server.mjs"
}
}The build generates contract.json from the claims and implementation types. The loader checks both files before it starts the server factory.
bb.agents.tool and bb.agents.ai use the keyed kind. Each key has one active winner. bb.agents.toolCalls uses the chain kind, so all active members can run.
2. Add the tool
Use a Standard Schema value for typed parameters. This example uses Zod.
// src/server.ts
import { z } from "zod";
import { defineServerPlugin } from "@get-bb/plugin/server";
import {
completeRepositoryInference,
repositoryFacts,
redactKnownSecrets,
} from "./repository-backend.js";
const parameters = z.object({
question: z.string().min(1).max(500),
});
export default defineServerPlugin((api) => {
api.tools.add({
name: "acme_repo_facts",
description: "Answer one question from the current repository index.",
instructions: "Use this tool for repository policy and architecture questions.",
parameters,
presentation: {
label: {
pending: "Reading repository facts",
completed: "Read repository facts",
},
icon: { glyph: "BookOpen" },
intent: "read",
},
async execute({ question }, context) {
const answer = await repositoryFacts.answer({
projectId: context.projectId,
question,
signal: context.signal,
});
return {
content: [{ type: "text", text: answer }],
};
},
});
api.agents.interceptToolCalls({
id: "redact-known-secrets",
async intercept(call, next) {
api.log.info(
`tool call ${call.callId}: ${call.tool.name} from ${call.tool.claimantPluginId}`,
);
return next({ input: redactKnownSecrets(call.input) });
},
});
api.ai.add({
id: "acme.repo-ai",
displayName: "Acme repository AI",
kinds: ["inference"],
inference: {
complete: completeRepositoryInference,
},
});
});The name is also the bb.agents.tool key. It must match [A-Za-z0-9_-]+ and contain no more than 64 characters.
Prefix a new tool name with your plugin ID. An intentional duplicate claim adds a candidate for that key.
The model reads description. The active session also receives instructions. The bb.threads plugin uses presentation to display the tool call.
The context supplies threadId, projectId, environmentId, hostId, providerId, and signal. Pass the abort signal to all slow work.
You can also supply a JSON Schema object as parameters. Standard Schema gives execute() a typed parameter value.
A tool can return a string or a content object. Content objects can contain text and image items.
Set isError: true when the tool returns useful error content. A thrown error still follows the runtime error path.
The presentation can also set suppress, light and dark tints, or another intent. The thread owner decides how these hints appear.
3. Implement the local backend adapter
Keep repository access and model access behind plugin-local functions. The public agent contracts do not require a specific model library.
// src/repository-backend.ts
import type {
AiCallContext,
AiInferenceRequest,
AiInferenceResult,
AiVoiceRequest,
AiVoiceResult,
JsonObject,
} from "@bb/agents/contracts";
import {
localModel,
ModelTimeoutError,
repositoryIndex,
secretRedactor,
} from "@acme/repository-runtime";
export const repositoryFacts = {
async answer(input: {
projectId: string;
question: string;
signal: AbortSignal;
}): Promise<string> {
return repositoryIndex.answer(input);
},
};
export function redactKnownSecrets(input: unknown): unknown {
return secretRedactor.redact(input);
}
export async function completeRepositoryInference(
input: AiInferenceRequest,
context: AiCallContext,
): Promise<AiInferenceResult> {
try {
const value: JsonObject = await localModel.completeJson({
model: input.model,
prompt: input.prompt,
outputSchema: input.outputSchema,
timeoutMs: input.timeoutMs,
signal: context.signal,
});
return { ok: true, model: input.model, value };
} catch (error) {
if (error instanceof ModelTimeoutError) {
return { ok: false, code: "timeout", message: "The AI call stopped." };
}
return {
ok: false,
code: "request_failed",
message: error instanceof Error ? error.message : "The AI call failed.",
};
}
}
export function transcribeRepositoryVoice(
input: AiVoiceRequest,
context: AiCallContext,
): Promise<AiVoiceResult> {
return localModel.transcribe({
...input,
signal: context.signal,
});
}The private @acme/repository-runtime package supplies the repository and model adapters. It does not form part of the bb contract.
The inference request contains a model, a prompt, an output schema, and a timeout. Its reasoningEffort value is "none".
The service must return a JSON object that matches the schema. It must return a classified failure instead of an untyped error.
Use one of these stable failure codes:
timeoutrate_limitedservice_unavailableauth_requiredrequest_failedinvalid_response
Keep the failure message safe for display. Do not include a secret or a private model response.
4. Understand tool replacement
bb.agents.tool is a replaceable keyed service. The kernel stores one global winner for each tool name.
If another plugin claims acme_repo_facts, the winner picker shows both candidates. The current winner does not change without a user choice.
The kernel starts a new winner before cutover. Existing calls drain to the kernel deadline. New calls use the new winner.
The default provider stays active if a selected candidate cannot start. Backend replacements never receive an Original handle.
A single app surface winner can receive Original. A backend service uses the declared default provider for fallback instead.
5. Understand the tool-call chain
The runtime selects the tool winner before it starts bb.agents.toolCalls. Each interceptor receives the selected name and claimant plugin ID.
An interceptor can inspect input, replace input, call next(), or return a tool result. It cannot select a different tool name.
The runtime lets each member call next() once. A direct result stops the rest of the chain and the selected tool.
The user controls the chain order in the contract inspector. Do not depend on plugin load order.
If an interceptor throws, the runtime returns an error tool result with interceptor attribution. Other agent work can continue.
6. Add voice support when you need it
An AI service can declare "voice" with "inference", or it can declare either kind alone. The methods must match the kinds list.
import { transcribeRepositoryVoice } from "./repository-backend.js";
api.ai.add({
id: "acme.repo-ai",
displayName: "Acme repository AI",
kinds: ["inference", "voice"],
inference: {
complete: completeRepositoryInference,
},
voice: {
transcribe: transcribeRepositoryVoice,
},
});The bb.agents.ai service supports small helper operations. It does not run an agent session. The bb.providers plugin owns provider bridges.
Use a private typed host role when the model must run on a selected host. Keep the public bb.agents.ai claim in the server artifact.
Each AI service ID follows the same winner rules as a tool key. The default service remains active until a selected candidate becomes ready.
The inference request has reasoningEffort: "none". It always includes an output schema and a caller timeout.
The voice request includes base64 audio, its MIME type, a filename, an optional prompt, and a caller timeout.
7. Test the selected tool path
The server test harness starts the real loader graph with controlled ports. Its tool driver uses the selected keyed winner and the interceptor chain.
import { expect, it } from "vitest";
import { createServerHarness } from "@get-bb/plugin/testing/server";
import server from "./server.js";
import { contract, manifest } from "./test-artifacts.js";
it("answers through the selected tool and policy chain", async () => {
const harness = await createServerHarness({
plugin: { manifest, contract, server },
placement: "inProcess",
});
const result = await harness.drivers.callAgentTool("acme_repo_facts", {
question: "Where are repository rules stored?",
});
expect(result).toMatchObject({
content: [{ type: "text" }],
});
await harness.dispose();
});The test-artifacts module exposes the manifest and generated contract as JSON values. Your build fixture can supply that small module.
Use harness.drivers.flipWinner() to test a replacement. Use harness.dispose() to check automatic cleanup and cancellation.
What happens at runtime
The loader validates the three claims and the generated contract.json. It then gives the server factory one typed api object.
The factory stages the tool, interceptor, and AI service. The loader commits all registrations as one plugin generation.
When an agent calls acme_repo_facts, the runtime selects its winner. It runs the ordered interceptor chain before it validates the final input.
The selected tool receives the call context and abort signal. Its text or image result returns to the agent and to the thread timeline.
The factory owns automatic cleanup for all three registrations. A reload or stop removes them together.
Pitfalls
- The tool
namemust equal the static claim key. The AI serviceidmust also equal its claim key. - An absent claim or an extra runtime registration fails the build or load. Check the generated
contract.json. - An interceptor can call
next()only once. A second call fails that tool call. - An interceptor cannot change the selected tool name. Use a replacement claim when you need another implementation.
- The AI
kindslist must match its method groups. Do not declare an absent method or an extra group. - Handle
signalandtimeoutMsin adapters. A plugin reload and a caller can both cancel work. - Do not expect
Originalin backend code. The global winner and default provider control fallback. bb.agentsowns no app surface. Usebb.threadscontracts if you must change tool-call display.
See also
- bb.agents design for full tool, configuration, AI, skill, and host-role contracts.
- Plugin authoring for factories, generated contracts, automatic cleanup, and server tests.
- Kernel design for winner selection, fallback, drain, and the stable kernel ports.
- Next page: Commands and interception.