bb.agents

The bb.agents plugin owns agent tools, helper AI services, skills, session configuration, and tool-call policy.

Purpose

bb.agents gives every agent capability a typed contract and a stable owner. It lets plugins add or replace tools and AI services without private registries. It also resolves skills and agent configuration before each agent turn.

Surfaces

ID kind replaceable props contract sketch notes
bb.agents owns no app surface. bb.threads renders tool calls and agent state.

These contracts have no spatial owner. They appear in the Plugin backend section of the contract inspector.

Services

ID kind replaceable contract sketch notes
bb.agents.tool keyed yes AgentTool keyed by name One winner supplies each model-visible tool name.
bb.agents.ai keyed yes AiService keyed by id One winner supplies each helper AI service.
bb.agents.configuration list no AgentConfigurationContribution The resolver combines all active contributions before each turn.
bb.agents.toolCalls chain no AgentToolCallInterceptor The chain wraps the selected tool call. The user controls chain order.
bb.agents.skills single yes AgentSkillsService The default service owns workspace, registry, and CLI skill operations.

bb.agents.tool

bb.agents.tool uses the model-visible tool name as its key. The contract accepts Standard Schema values and JSON Schema objects. The Standard Schema form gives execute a typed input.

import type { StandardSchemaV1 } from "@standard-schema/spec";

export type JsonObject = { readonly [key: string]: JsonValue };
export type JsonValue =
  | null
  | boolean
  | number
  | string
  | readonly JsonValue[]
  | JsonObject;

export type AgentToolContent =
  | { type: "text"; text: string }
  | { type: "image"; data: string; mimeType: string };

export type AgentToolResult =
  | string
  | {
      content: readonly AgentToolContent[];
      isError?: boolean;
    };

export interface AgentToolLabels {
  pending: string;
  completed: string;
}

export interface AgentToolPresentation {
  label?: AgentToolLabels;
  icon?: { glyph: string };
  suppress?: boolean;
  tint?: { light: string; dark: string };
  intent?: "generic" | "terminal" | "diff" | "search" | "read" | "list" | "web" | "image";
}

export interface AgentToolContext {
  threadId: string;
  projectId: string;
  environmentId: string | null;
  hostId: string;
  providerId: string;
  signal: AbortSignal;
}

export interface AgentToolBase {
  name: string;
  description: string;
  instructions?: string;
  presentation?: AgentToolPresentation;
}

export interface AgentTool<Parameters = unknown> extends AgentToolBase {
  parameters: StandardSchemaV1<unknown, Parameters> | JsonObject;
  execute(
    parameters: Parameters,
    context: AgentToolContext,
  ): AgentToolResult | Promise<AgentToolResult>;
}

export interface AgentToolsApi {
  add<Schema extends StandardSchemaV1>(
    tool: AgentTool<StandardSchemaV1.InferOutput<Schema>> & { parameters: Schema },
  ): { dispose(): void };

  add(
    tool: AgentTool<unknown> & { parameters: JsonObject },
  ): { dispose(): void };
}

The name must match [A-Za-z0-9_-]+ and contain no more than 64 characters. The plugin should prefix a new name with its plugin ID. A deliberate collision creates another claimant for that key.

The manifest claim makes the key visible before server code starts. The factory call supplies the behavior during the atomic plugin commit.

{
  "requires": [
    { "service": "bb.agents.tool", "range": "^1" }
  ],
  "claims": [
    {
      "service": "bb.agents.tool",
      "version": "1.0.0",
      "key": "acme_search_issues"
    }
  ]
}
import { z } from "zod";
import { defineServerPlugin } from "@get-bb/plugin/server";

export default defineServerPlugin((api) => {
  api.tools.add({
    name: "acme_search_issues",
    description: "Search Acme issues by text.",
    instructions: "Use this tool before you create a duplicate issue.",
    parameters: z.object({ query: z.string().min(1) }),
    presentation: {
      label: { pending: "Searching issues", completed: "Searched issues" },
      icon: { glyph: "Search" },
      intent: "search",
    },
    async execute({ query }, context) {
      const issues = await searchIssues(query, context.signal);
      return { content: [{ type: "text", text: formatIssues(issues) }] };
    },
  });
});

api.tools.add stages one bb.agents.tool provision. The loader checks its static claim, key, version, and contract.json schema. The runtime releases the provision when the plugin stops.

Each tool key has one default claimant and one current winner. A winner change starts the candidate before cutover. The old call drains to the kernel deadline, and new calls use the new winner. The backend never gives a replacement an Original handle.

bb.agents.ai

bb.agents.ai provides small helper AI operations. It does not replace the provider bridge that runs an agent session. bb.providers owns that provider bridge.

export type AiServiceKind = "inference" | "voice";

export type AiServiceErrorCode =
  | "timeout"
  | "rate_limited"
  | "service_unavailable"
  | "auth_required"
  | "request_failed"
  | "invalid_response";

export type AiServiceFailure = {
  ok: false;
  code: AiServiceErrorCode;
  message: string;
};

export interface AiCallContext {
  threadId?: string;
  projectId?: string;
  hostId: string;
  signal: AbortSignal;
}

export interface AiInferenceRequest {
  model: string;
  reasoningEffort: "none";
  prompt: string;
  outputSchema: JsonObject;
  timeoutMs: number;
}

export type AiInferenceResult =
  | { ok: true; model: string; value: JsonObject }
  | AiServiceFailure;

export interface AiVoiceRequest {
  model: string;
  audioBase64: string;
  mimeType: string;
  filename: string;
  prompt: string | null;
  timeoutMs: number;
}

export type AiVoiceResult =
  | { ok: true; model: string; text: string }
  | AiServiceFailure;

export interface AiService {
  readonly id: string;
  readonly displayName: string;
  readonly kinds: readonly AiServiceKind[];
  inference?: {
    complete(input: AiInferenceRequest, context: AiCallContext): Promise<AiInferenceResult>;
  };
  voice?: {
    transcribe(input: AiVoiceRequest, context: AiCallContext): Promise<AiVoiceResult>;
  };
}

export interface AgentAiApi {
  add(service: AiService): { dispose(): void };
}

The kinds list and the method groups must match. The contract build rejects a missing declared method. It also rejects an undeclared method group.

The service ID is the keyed contract key. The public method input does not repeat that ID. The host role request includes the ID because one host artifact can serve several keys.

{
  "requires": [
    { "service": "bb.agents.ai", "range": "^1" }
  ],
  "claims": [
    {
      "service": "bb.agents.ai",
      "version": "1.0.0",
      "key": "acme.local-ai"
    }
  ]
}
const aiHost = api.host.role(aiHostRole);

api.ai.add({
  id: "acme.local-ai",
  displayName: "Acme Local AI",
  kinds: ["inference", "voice"],
  inference: {
    complete: (input, context) =>
      aiHost.call(
        "inference.complete",
        { serviceId: "acme.local-ai", ...input },
        { hostId: context.hostId, signal: context.signal },
      ),
  },
  voice: {
    transcribe: (input, context) =>
      aiHost.call(
        "voice.transcribe",
        { serviceId: "acme.local-ai", ...input },
        { hostId: context.hostId, signal: context.signal },
      ),
  },
});

Each AI service key follows the same winner and fallback rules as an agent tool. The default provider stays live until the selected candidate becomes ready.

bb.agents.configuration

This list contract combines contributions from all active plugins. The runtime resolves the list before each agent turn. A contribution can use stable thread, project, environment, host, provider, and origin data.

export interface AgentConfigurationContext {
  thread: {
    id: string;
    title: string | null;
    parentThreadId: string | null;
    sourceThreadId: string | null;
  };
  project: {
    id: string;
    kind: "standard" | "personal";
    name: string;
    gitRemoteUrl: string | null;
  };
  environment: {
    id: string;
    name: string | null;
    path: string | null;
    workspaceProvisionType: string;
    branchName: string | null;
  };
  host: { id: string; name: string };
  provider: {
    id: string;
    model: string;
    capabilities: { supportsNativeUserQuestion: boolean };
  };
  origin: {
    kind: "fork" | null;
    pluginId: string | null;
  };
}

export type AgentConfigurationScope =
  | { kind: "all" }
  | { kind: "project"; projectId: string }
  | { kind: "thread"; threadId: string };

export interface AgentToolSelection {
  name: string;
  parameters?: JsonObject;
}

export interface AgentToolSet {
  include?: readonly (string | AgentToolSelection)[];
  exclude?: readonly string[];
}

export interface AgentInstructions {
  text: string;
  mode: "append" | "prepend" | "replace";
}

export interface SkillRef {
  rootId: string;
  path: string;
}

export interface AgentConfiguration {
  scope?: AgentConfigurationScope;
  tools?: AgentToolSet;
  skills?: readonly SkillRef[];
  instructions?: AgentInstructions;
  env?: Readonly<Record<`BB_${string}`, string>>;
}

export interface AgentConfigurationContribution {
  id: string;
  resolve(
    context: AgentConfigurationContext,
  ): AgentConfiguration | null | Promise<AgentConfiguration | null>;
}

export interface AgentsApi {
  configure(
    contribution: AgentConfigurationContribution,
  ): { dispose(): void };

  contributeInstructions(
    provider: (
      context: AgentConfigurationContext,
    ) => string | null | Promise<string | null>,
  ): { dispose(): void };

  interceptToolCalls(
    interceptor: AgentToolCallInterceptor,
  ): { dispose(): void };
}

The contribution ID needs only plugin-local uniqueness. The runtime forms the full item ID as <pluginId>/<contributionId>. The contract inspector shows this full ID.

The resolver applies these rules in list order.

Field Merge rule
scope The resolver ignores a contribution outside its scope.
tools.exclude The names leave the active tool set.
tools.include The names enter the active tool set. A later include can restore a name.
tools.include[].parameters The schema override applies only to this turn. It cannot widen the tool's validated input.
skills The resolver keeps the last equal {rootId, path} reference.
instructions prepend adds text first. append adds text last. replace clears prior text first.
env A later value wins. The key must start with BB_.

contributeInstructions supplies an append contribution. It gives the contribution a stable generated ID. A resolver error removes only that contribution from the current turn. The turn continues and the runtime records the error against its plugin.

bb.agents.toolCalls

This chain wraps every call after the runtime selects a tool winner. An interceptor can inspect input, replace input, call the next member, or return a result. It cannot change the selected tool name.

export interface AgentToolCall {
  callId: string;
  tool: {
    name: string;
    claimantPluginId: string;
    description: string;
  };
  input: unknown;
  context: AgentToolContext;
}

export interface AgentToolCallPatch {
  input?: unknown;
}

export type AgentToolCallNext = (
  patch?: AgentToolCallPatch,
) => Promise<AgentToolResult>;

export interface AgentToolCallInterceptor {
  id: string;
  intercept(
    call: AgentToolCall,
    next: AgentToolCallNext,
  ): Promise<AgentToolResult>;
}

The manifest claims the chain contract. The server factory provides one member.

{
  "claims": [
    { "service": "bb.agents.toolCalls", "version": "1.0.0" }
  ]
}
api.agents.interceptToolCalls({
  id: "redact-secrets",
  async intercept(call, next) {
    const input = redactKnownSecrets(call.input);
    return next({ input });
  },
});

The runtime lets each member call next once. A direct result stops the chain. A thrown error becomes an error tool result with interceptor attribution. The inspector shows the active order and lets the user change it.

bb.agents.skills

The skills service owns workspace skill files and registry operations. It uses stable identities instead of raw absolute paths at the service boundary.

export interface SkillIdentity {
  rootId: string;
  path: string;
}

export interface SkillSummary extends SkillIdentity {
  name: string;
  description: string;
  scope: "user" | "project";
  revision: string;
}

export interface SkillContent extends SkillSummary {
  content: string;
}

export interface SkillFile {
  path: string;
  size: number;
}

export interface SkillRegistrySource {
  id: string;
  repository: string;
  revision?: string;
}

export interface RegistrySkill {
  id: string;
  name: string;
  description: string;
  source: SkillRegistrySource;
}

export interface RegistrySkillPage {
  items: readonly RegistrySkill[];
  nextCursor: string | null;
}

export interface AgentSkillsRegistryService {
  search(input?: {
    query?: string;
    cursor?: string;
    limit?: number;
  }): Promise<RegistrySkillPage>;

  get(input: { id: string }): Promise<RegistrySkill>;

  detail(input: {
    source: SkillRegistrySource;
  }): Promise<{ source: SkillRegistrySource; readme: string | null }>;

  entries(input: {
    source: SkillRegistrySource;
  }): Promise<{ entries: readonly RegistrySkill[] }>;

  install(input: {
    id: string;
    projectId?: string;
    rootId?: string;
  }): Promise<{ skill: SkillSummary; filePath: string }>;

  repositoryStars(input: {
    repository: string;
  }): Promise<{ stars: number | null }>;
}

export interface AgentSkillsCliService {
  status(input?: {
    hostId?: string;
  }): Promise<{
    hosts: readonly {
      hostId: string;
      installed: boolean;
      revision: string | null;
    }[];
  }>;

  install(input: {
    hostId?: string;
  }): Promise<{
    hosts: readonly {
      hostId: string;
      installedPath: string;
      revision: string;
    }[];
  }>;
}

export interface AgentSkillsService {
  list(input: {
    projectId?: string;
    scope?: "user" | "project";
  }): Promise<readonly SkillSummary[]>;

  getContent(input: SkillIdentity): Promise<SkillContent>;

  listFiles(input: SkillIdentity): Promise<readonly SkillFile[]>;

  update(input: SkillIdentity & {
    content: string;
    expectedRevision?: string;
  }): Promise<{ filePath: string; revision: string }>;

  remove(input: SkillIdentity & {
    expectedRevision?: string;
  }): Promise<{ deletedPath: string }>;

  readonly registry: AgentSkillsRegistryService;
  readonly cli: AgentSkillsCliService;
}

The default service rejects a path that escapes its declared root. update uses the optional revision for compare-and-set behavior. A winner change restarts required consumers and preserves kernel-backed skill files.

A server plugin consumes the current skills winner through a required edge.

{
  "requires": [
    { "service": "bb.agents.skills", "range": "^1" }
  ]
}
import { bbAgentSkills } from "@bb/agents/contracts";

const skills = await api.services.use(bbAgentSkills);
const available = await skills.list({ projectId: "project_123" });

Exports

Module imports select the exact first-party implementation. Service tokens select the user's current winner.

module exports use
@bb/agents/contracts bbAgentTool, bbAgentAi, bbAgentConfiguration, bbAgentToolCalls, bbAgentSkills Typed service tokens and contract types.
@bb/agents/server bbAgentTools, bbAgentAi, bbAgentConfiguration, and definition helpers Typed domain helpers for server plugins.
@bb/agents/host defineAiHostRole, AI host input schemas, AI host output schemas Shared shapes for a plugin-private AI host role.
@bb/agents/skills parseSkillManifest, validateSkillRelativePath Pure skill helpers.

bb.agents exports no React component. bb.threads owns the timeline components that display agent tool calls.

Host roles

An AI service can run on the server or use a host artifact. The server half still owns the public bb.agents.ai key. The host artifact fulfills a private role with the plugin's namespace.

The shared module supplies this role shape. The role ID in this example is acme.local-ai.host.

export interface AiInferenceHostInput extends AiInferenceRequest {
  serviceId: string;
}

export interface AiVoiceHostInput extends AiVoiceRequest {
  serviceId: string;
}

export interface AiHostCommands {
  "inference.complete": {
    input: AiInferenceHostInput;
    output: AiInferenceResult;
  };
  "voice.transcribe": {
    input: AiVoiceHostInput;
    output: AiVoiceResult;
  };
}

export const aiHostRole = defineHostRole<AiHostCommands>(
  "acme.local-ai.host",
  "1.0",
);
{
  "artifacts": {
    "server": "./dist/server.js",
    "host": "./dist/host.js"
  },
  "hostRoles": [
    {
      "id": "acme.local-ai.host",
      "version": "1.0.0",
      "contract": "./src/ai-host-contract.ts#aiHostRole",
      "required": true
    }
  ]
}
// host.ts
export default defineHostPlugin((api) => {
  api.roles.provide(aiHostRole, {
    async "inference.complete"(input, context) {
      return completeLocally(input, context.signal);
    },
    async "voice.transcribe"(input, context) {
      return transcribeLocally(input, context.signal);
    },
  });
});

The host role uses explicit typed verbs. The server call always selects a host and carries an abort signal. The inspector joins the private role with the public AI service record. The host contract replaces ExperimentalAiServicesHostContract and its old gap alias.

Example

This plugin adds a repository facts tool and limits it to Acme projects. It also adds a tool-call audit member.

// bb.plugin.jsonc
{
  "id": "acme.repo-facts",
  "version": "2.0.0",
  "claims": [
    {
      "service": "bb.agents.tool",
      "version": "1.0.0",
      "key": "acme_repo_facts"
    },
    { "service": "bb.agents.configuration", "version": "1.0.0" },
    { "service": "bb.agents.toolCalls", "version": "1.0.0" }
  ],
  "requires": [
    { "service": "bb.agents.tool", "range": "^1" },
    { "service": "bb.agents.configuration", "range": "^1" },
    { "service": "bb.agents.toolCalls", "range": "^1" }
  ],
  "artifacts": {
    "server": "./dist/server.js"
  }
}
// server.ts
import { z } from "zod";
import { defineServerPlugin } from "@get-bb/plugin/server";

const parameters = z.object({
  question: z.string().min(1),
});

export default defineServerPlugin((api) => {
  api.tools.add({
    name: "acme_repo_facts",
    description: "Answer one question from the current repository index.",
    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 answer;
    },
  });

  api.agents.configure({
    id: "acme-projects",
    resolve(context) {
      const remote = context.project.gitRemoteUrl;
      if (!remote?.includes("github.com/acme/")) return null;
      return {
        tools: { include: ["acme_repo_facts"] },
        instructions: {
          mode: "append",
          text: "Use acme_repo_facts for questions about repository policy.",
        },
      };
    },
  });

  api.agents.interceptToolCalls({
    id: "audit-repo-facts",
    async intercept(call, next) {
      if (call.tool.name !== "acme_repo_facts") return next();
      api.log.info(`repo facts call ${call.callId}`);
      return next();
    },
  });
});

The loader validates all three static claims before it starts the factory. The factory stages all three registrations in one atomic commit. An unload removes the tool, configuration item, and interceptor together.

Covers

old item ID new contract/verb note
server.agents api.agents and api.tools The direct APIs provide configuration, interception, and keyed tool registration.
server.agents.configure api.agents.configure() A list contribution resolves configuration for each turn.
server.agents.registerTool api.tools.add() The call provides one bb.agents.tool key.
server.agents.contributeInstructions api.agents.contributeInstructions() The shortcut adds an append configuration contribution.
server.agents.toolRegistration bb.agents.tool: AgentTool AgentTool holds metadata, parameters, and execution.
server.agents.toolPresentation bb.agents.tool: AgentToolPresentation The presentation remains data for the timeline owner.
server.agents.toolContext bb.agents.tool: AgentToolContext The context adds environment, host, and provider IDs.
server.agents.configuration bb.agents.configuration: AgentConfiguration The list resolver combines this shape.
server.experimental_aiServices api.ai The stable API registers bb.agents.ai keys.
server.experimental_aiServices.register api.ai.add() The call provides one keyed AI service.
server.experimental_aiServices.declaration bb.agents.ai: AiService The public service joins metadata and methods.
server.agents.toolRegistration.name bb.agents.tool: AgentTool.name The name is the keyed contract key.
server.agents.toolRegistration.description bb.agents.tool: AgentTool.description The model reads this description.
server.agents.toolRegistration.instructions bb.agents.tool: AgentTool.instructions Active tool instructions join session instructions.
server.agents.toolRegistration.parameters bb.agents.tool: AgentTool.parameters Standard Schema and JSON Schema forms remain available.
server.agents.toolRegistration.execute bb.agents.tool: AgentTool.execute() The selected winner handles the call.
server.agents.toolPresentation.label bb.agents.tool: AgentToolPresentation.label Pending and completed labels remain.
server.agents.toolPresentation.icon bb.agents.tool: AgentToolPresentation.icon The glyph remains host-rendered data.
server.agents.toolPresentation.suppress bb.agents.tool: AgentToolPresentation.suppress The default-collapse hint remains.
server.agents.toolPresentation.tint bb.agents.tool: AgentToolPresentation.tint Light and dark tint values remain.
server.agents.configuration.tools bb.agents.configuration: AgentToolSet The old array becomes the include list with an explicit exclude list.
server.agents.configuration.skills bb.agents.configuration: AgentConfiguration.skills Values use stable {rootId, path} references.
server.agents.configuration.instructions bb.agents.configuration: AgentInstructions The shape adds an explicit merge mode.
server.agents.toolSelection.name bb.agents.configuration: AgentToolSelection.name The selection names one bb.agents.tool key.
server.agents.toolSelection.parameters bb.agents.configuration: AgentToolSelection.parameters The per-turn schema restriction remains.
server.experimental_aiServices.declaration.id bb.agents.ai: AiService.id The ID is the keyed contract key.
server.experimental_aiServices.declaration.displayName bb.agents.ai: AiService.displayName The user-facing label remains.
server.experimental_aiServices.declaration.kinds bb.agents.ai: AiService.kinds The list declares inference and voice methods.
server.agents.toolResult bb.agents.tool: AgentToolResult Text and image content remain.
server.agents.toolLabels bb.agents.tool: AgentToolLabels Pending and completed labels remain a pair.
server.agents.configurationContext bb.agents.configuration: AgentConfigurationContext The stable session context remains available.
server.sdk.skills bb.agents.skills service The named service replaces the broad SDK area.
server.sdk.skills.getContent bb.agents.skills service: getContent() The method reads one skill.
server.sdk.skills.list bb.agents.skills service: list() The method lists skill summaries.
server.sdk.skills.listFiles bb.agents.skills service: listFiles() The method lists files under one skill.
server.sdk.skills.remove bb.agents.skills service: remove() The method removes one skill by stable identity.
server.sdk.skills.update bb.agents.skills service: update() The method writes content with an optional revision check.
server.sdk.skills.registry bb.agents.skills service: registry The nested service owns registry operations.
server.sdk.skills.registry.detail bb.agents.skills service: registry.detail() The method reads source details.
server.sdk.skills.registry.entries bb.agents.skills service: registry.entries() The method lists source entries.
server.sdk.skills.registry.get bb.agents.skills service: registry.get() The method reads one registry skill.
server.sdk.skills.registry.install bb.agents.skills service: registry.install() The method installs one registry skill.
server.sdk.skills.registry.repositoryStars bb.agents.skills service: registry.repositoryStars() The method reads repository stars.
server.sdk.skills.registry.search bb.agents.skills service: registry.search() The method searches the registry.
server.sdk.system.cliSkillsStatus bb.agents.skills service: cli.status() Skill install state moves out of the system SDK area.
server.sdk.system.installCliSkills bb.agents.skills service: cli.install() CLI skill installation moves to the skill owner.
host.ai.contract <pluginId>.aiHost role: AiHostCommands A private typed role replaces the shared experimental host contract.
host.ai.errorCode @bb/agents/host: AiServiceErrorCode The stable error code union remains.
host.ai.inference.input <pluginId>.aiHost role: AiInferenceHostInput The request keeps the service ID for host dispatch.
host.ai.inference.output <pluginId>.aiHost role: AiInferenceResult The structured result and classified failure remain.
host.ai.inference.complete <pluginId>.aiHost role: inference.complete The explicit host verb remains.
host.ai.voice.input <pluginId>.aiHost role: AiVoiceHostInput The request keeps audio, file, prompt, and timeout data.
host.ai.voice.output <pluginId>.aiHost role: AiVoiceResult The text result and classified failure remain.
host.ai.voice.transcribe <pluginId>.aiHost role: voice.transcribe The explicit host verb remains.
host.api.aiServices api.ai The stable direct API replaces the experimental property.
host.api.aiServices.register api.ai.add() The server half provides a host-backed AI service.
host.ai.declaration bb.agents.ai: AiService One public keyed service replaces the declaration-only form.
host.ai.declaration.id bb.agents.ai: AiService.id The ID selects the keyed winner.
host.ai.declaration.displayName bb.agents.ai: AiService.displayName The label remains.
host.ai.declaration.kinds bb.agents.ai: AiService.kinds The list must match the service method groups.
host.experimental_aiServicesHostContract <pluginId>.aiHost role: AiHostCommands This gap ID maps to the same private role as host.ai.contract.
server.sdk.system.transcribeVoice bb.agents.ai: AiService.voice.transcribe() The selected AI service transcribes the recording.