Skip to content
Claude edited this page Oct 22, 2026 · 28 revisions

Tools

A tool is a @Tool class in its workflow's tools/ folder — see Components for the pattern (constructor dependencies, ToolHandler, tests with new).

import { ExecutionContext, Tool, type ToolHandler } from "agboss";
import { ListOf, Text } from "agboss/dto";
import { seconds } from "agboss/units";

export class Issue {
  @Text() title!: string;
  @Text() url!: string;
}

export class IssueQuery {
  @Text({ prompt: "words to search the issues for" })
  query!: string;
}

export class IssueList {
  @ListOf(Issue)
  issues!: Issue[];
}

@Tool({
  name: "search-issues-tool",
  description: "Search the team's issues",
  input: IssueQuery,
  output: IssueList,
  timeout: seconds(10), // with units — there is no timeoutMs
  deps: [ExecutionContext, ISSUES_API],
})
export class SearchIssues implements ToolHandler<IssueQuery, IssueList> {
  constructor(
    private readonly executionContext: ExecutionContext,
    private readonly issuesApi: IssuesApi,
  ) {}

  run({ query }: IssueQuery): Promise<IssueList> {
    return this.issuesApi.search(query, this.executionContext.signal);
  }
}

Input and output are DTO classes (Components → DTOs); the model sees the input DTO's JSON Schema, with each field's prompt as its description.

Contract

Step of tool.invoke(raw) On failure
input validated against the input DTO error: invalid input: <path>: <issue> (jobs[2].url) — run is not called
run is not started if the run is already cancelled error: <abort reason>
run under its timeout and the run's AbortSignal error: timed out after 10 s / error: <message>
output validated against the output DTO error: invalid output: … — a contract bug of the tool

Results are values — ToolResult = { kind: "ok", value } | { kind: "error", message } — never exceptions: the model sees Tool error: … and can recover.

run gets only its input (#280 — there is no ToolContext). The execution it runs in comes through the constructor: deps: [ExecutionContext] gives executionId, threadId, ownerId, signal, workflow and the call's step (callId, idempotencyKey, …) (Context and Authorization). A tool's model spend through ROUTER_FACTORY is recorded as the tool's; what it pays a service that is not a model it records with deps: [SpendRecorder] — this.spendRecorder.record({ service, cost: usd(…) }), counted in the money limits (FinOps).

Names

A tool's name is named like every component (#268 AC9): kebab-case ending with -tool — search-issues-tool, @McpTool too ([name.kind-suffix] at assembly, with the name to use: "search_issues" on @Tool — name it "search-issues-tool"). A knowledge base's tool is search-<rag name>-tool (search-jobs-rag-tool for jobs-rag, Knowledge bases). The tools an MCP server offers keep the server's own names (read_text_file, declared in @McpServer({ tools })) — a model never sees them, only the @McpTool calling them. Every name a model sees — the framework's answer tools (answer-<dto>) too — fits what the providers accept (OpenAI, Anthropic, Gemini): [a-zA-Z0-9_-], at most 64 characters ([tool.name-too-long], [tool.name-invalid]), and is unique over the agent's tools ([tool.duplicate-name]). All found at assembly, at once, before any model call.

Timeouts

A call runs under its tool's timeout — a value with units, timeout: seconds(10) (agboss/units; default 30 s); a bare number does not compile and there is no timeoutMs (#268 AC11). The timer runs on the application's Clock, so a test's clock.advance(…) moves it.

Result size

What a model reads of one tool result is bounded (#336), so one huge page or JSON cannot overflow the model's context on the next turn of the loop: maxResultSize, in bytes of the UTF-8 text the model reads, given with kilobytes() (agboss/units, 1 KB = 1,024 bytes). The tool's own wins; else defaults.tools.maxResultSize of its workflow, else of its application (Configuration → defaults.tools); nothing set → 128 KB.

// tools/fetch-page.tool.ts — a tool whose pages may be long sets its own
import { Tool, type ToolHandler } from "agboss";
import { kilobytes, seconds } from "agboss/units";

@Tool({
  name: "fetch-page-tool",
  description: "Fetches a web page as text",
  input: PageQuery,
  output: PageContent,
  timeout: seconds(10),
  maxResultSize: kilobytes(256),
  deps: [PageReader],
})
export class FetchPageTool implements ToolHandler<PageQuery, PageContent> {
  constructor(private readonly pageReader: PageReader) {}

  async run({ url }: PageQuery): Promise<PageContent> {
    return { text: await this.pageReader.textOf(url) };
  }
}
  • The bound applies to the rendered result — the JSON of the output DTO, or Tool error: … for a failed call — of a @Tool, an @McpTool and a knowledge base's search-<rag>-tool alike. Over it, the text is cut at the last whitespace (space, tab, line break) within the final 256 bytes it keeps, else at the bound, never inside a character, and ends with \n[result truncated: 1,048,587 bytes, the first 262,144 kept] — a framework text the model reads, part of every workflow's configHash. The result stays useful; it is never turned into an error.
  • It is cut before PII masking: the policies read only the kept part, the marker follows it in what the model reads and in the checkpoint.
  • A knowledge base in RagMode.Context is bounded the same way: each knowledge base's block in the agent's input message by the agent's maxResultSize (its workflow's defaults.tools, else 128 KB).
  • onToolEnd carries isResultTruncated (update then ends with the marker, Observability); agboss describe prints each tool's bound and where it came from — maxResultSize 64 KB (desk-application), 256 KB (own), 128 KB (built-in) — and describe --json has it in bytes: schemas.tools[name].maxResultSize and each agent's maxResultSize (its context blocks and the tools without their own).
  • A bound that is not a whole number of bytes from 1 (kilobytes(0), kilobytes(0.1)) fails assembly with [tools.max-result-size-invalid], naming the tool or the workflow, all at once.
  • testTool(…).run returns the tool's full output — the bound is what a model reads, not what a tool returns.

Library and workflow tools

A tool lives in its workflow's tools/ folder (the framework ships none). There is no central registry: an agent's tools: [SearchIssues] is the registration.

MCP

A server declares the tools it offers that the workflow uses (@McpServer({ tools }), extends McpServerClient); an @McpTool is a tool with that server injected, calling it in run — see Components → MCP. Env variable NAMES only in the config, never secrets. A server tool that answers with plain text declares output: PlainTextDto (agboss/dto, the one field text) — the server's text arrives as { text }; agboss g mcp writes that by default.

Agents with tools

// agents/scout.agent.ts — the workflow sets defaults.tools.maxToolCalls; an agent may override it
@Agent({
  name: "scout",
  description: "Finds jobs",
  promptUrls: ["./scout.prompt.md"],
  model: "moonshotai/kimi-k2.6",
  tools: [GreenhouseJobs],
  maxToolCalls: 4,
})
export class Scout {}
  • tools lists classes — a missing or not-imported tool does not compile; at startup names are re-checked against the registry (UnknownAgentToolError).
  • The agent runs the framework's own loop (Agents): usage recorded per model call, the execution's budgets checked before each call (one exhausted → BudgetExceededError), maxToolCalls → agents.<name>.limits.toolCalls (default 20) fails the run with LimitExceededError; the loop's spend still reaches the budgets' counters.
  • Each call runs as its own task with its own signal; parallel calls of one turn run concurrently. In the call, executionContext.signal is the call's (#280 S45): the execution's signal (cancel included) combined with a timer of the tool's timeout (default 30 s), cleared when the call ends — so fetch(url, { signal: this.executionContext.signal }) stops on a cancel and on the call's own timeout, while the execution goes on (the model reads Tool error: timed out after 10 s).
  • executionContext.step.callId is the call's id — the same when a call interrupted by a crash runs again — and executionContext.step.idempotencyKey (${executionId}:${nodeId}:${visitNumber}[:${itemIndex}]:${callId}) the key a tool with side effects must pass to the external service. The key is unique per call (#335): a call the model gave no id gets one from its run, visit, turn and position, and an id the provider repeats within one move gets its position (call_0:0, call_0:1) — so two calls of one tool never share a key, and a recovered step replays the same ids.
  • An instance that loses the tree's lease mid-loop — taken by another instance, or its lease store silent until the lease ran out — aborts executionContext.signal with ExecutionLeaseLostError and starts no further model or tool call (Several instances → The execution lease).
  • Tool errors do not fail the run: the model reads Tool error: … and continues.

For the model

toolDefinitionOf(tool) describes a tool to the agent's chat model (bindTools): name, description and the JSON Schema of the input; validation, timeout and error mapping stay in invoke.

Isolation and tests

src/tools imports only config, finops, llm; the rest of the code imports it only through src/tools/index.ts (ESLint) — inside the framework; tool tests run without network.

Decisions: ADR-0003.

Who can use which tool

An agent's tools lists class references, so a missing or not-imported tool is a compile error. To see it all at once — no API key or network needed:

npm run describe
  notes  moonshotai/kimi-k2.6 · thinking default · history 5 turns + 10 summaries
    · note_search (read, local) ← NOTES_FILE — Find the user's saved notes …
    · note_save (write, local, waits for approval) ← NOTES_FILE — Save a note for the user.
unassigned tools: none

Approval channels

A tool that needs a decision before it runs names a channel: @Tool({ channel }) (or @McpTool). Channels are found through the tools that name them, like services through deps — there is no channelClasses (#267) — and one instance serves every workflow of the application. A tool names either a concrete @Channel as is (TerminalUserChannel, AutoApproveChannel, AutoRejectChannel) or a channel role — the project's abstract class that says what it approves and who may answer:

import {
  Channel,
  ChannelOf,
  ChannelOutcome,
  ChannelRole,
  ExecutionContext,
  PauseAnswerer,
  Tool,
  type ChannelAsk,
  type ToolHandler,
} from "agboss";

// channels/refund-approval.channel.ts — the role: what it approves, who answers
@ChannelRole({
  name: "refund-approval-channel",
  description: "Approves card refunds",
  approves: RefundCardInputDto, // the tools' input DTO — imports go DTO ← role ← tool
  answeredBy: PauseAnswerer.Channel,
})
export abstract class RefundApprovalChannel extends ChannelOf<RefundApprovalChannel> {}

// channels/slack-refund-approval.channel.ts — its implementation, listed in the application
@Channel({
  name: "slack-refund-approval-channel",
  description: "Asks a finance manager in Slack",
  deps: [ExecutionContext, SlackService],
})
export class SlackRefundApprovalChannel extends RefundApprovalChannel {
  constructor(
    private readonly executionContext: ExecutionContext,
    private readonly slackService: SlackService,
  ) {
    super();
  }

  async send(ask: ChannelAsk<RefundApprovalChannel>): Promise<ChannelOutcome<RefundApprovalChannel>> {
    const cents: number = ask.value.arguments.amountCents; // typed by RefundCardInputDto
    const tenant = this.executionContext.tenant.tenantId;
    await this.slackService.post("#finance", `Refund ${String(cents)}¢ (${tenant})?`, ask.pauseId);
    return ChannelOutcome.awaitReply();
  }
}

// tools/refund-card.tool.ts — the tool names the role
@Tool({
  name: "refund-card-tool",
  description: "Refunds a card payment",
  input: RefundCardInputDto,
  output: RefundCardOutputDto,
  channel: RefundApprovalChannel,
})
export class RefundCardTool implements ToolHandler<RefundCardInputDto, RefundCardOutputDto> {
  run({ orderId }: RefundCardInputDto): Promise<RefundCardOutputDto> {
    return Promise.resolve({ status: `refunded ${orderId}` });
  }
}
  • @ChannelRole({ name, description, answeredBy, approves | ask + answer, replyInterpreter? }) — two forms, never both ([channel.role-form]): approves: XDto for a tool approval (the question is ToolCallApprovalAskDto with arguments: XDto, the answer ToolCallApprovalDecisionDto with overrideArguments?: XDto), or ask / answer for a workflow question. answeredBy is required — PauseAnswerer.ThreadOwner or PauseAnswerer.Channel, no default. A tool whose input is not its role's approves (or that names a question role) fails assembly with [channel.wrong-tool]; one replyInterpreter class in two roles with [channel.shared-interpreter]. replyInterpreter is a @ReplyInterpreter class (#267 PR 3) that reads a free-text app.resume({ pauseId, reply }) with the pause's question into the role's answer — by its chat model, for an approval role a decision model (approve / refuse only, #267 S124), or by its code, which returns a ReplyInterpretation (Pauses → free text).
  • The implementation is a @Channel that extends the role, listed in @Application({ providers }) — found by inheritance, like any contract: none → [di.missing-implementation], two → [di.ambiguous-implementation]; a @Channel in a workflow's providers → [di.infrastructure-in-workflow] (channel transport is infrastructure). Different questions to different people are different roles; one question to several places, or a place chosen by the request's data, is one implementation that does it inside. Child workflows' tools count too: their roles need an implementation in the parent's application.
  • send(ask: ChannelAsk<Role>): Promise<ChannelOutcome<Role>> is the one method: ask is { pauseId, value } (the value typed by the generated ChannelRoles table — agboss check; ChannelQuestionOf<Role> is the question, ChannelAnswerOf<Role> the answer decided takes; the execution through deps). ChannelOutcome.awaitReply() — the call pauses and is saved until the reply (Pauses, Pause seam); ChannelOutcome.decided(decision) — the answer now (a rule such as "up to $50 at once", AutoApproveChannel): no pause, the decision validated like a reply, its pause record closed at once (PauseResolution.Decided). A send that returns anything else fails the step.
  • Who answers (#267 PR 2): the role's answeredBy. A PauseAnswerer.ThreadOwner pause is answered with the thread's ownerId and its pauseId is listed in result.pending; a PauseAnswerer.Channel pause — the reply comes through the channel's own system — is answered with app.resume({ pauseId: ask.pauseId, decision }, { answeredBy: "manager-17" }), its id never listed (it is the right to answer). The pauseId is made once per pause, before the first send: a retried send gets the same one — use it as the idempotency key of the post. Several pauses at once, concurrent answers, validation: Pauses.
  • Not yet: choosing an implementation per environment (a terminal in dev, Slack in prod) is a documented limit: one implementation per application.

Testing

A tool is tested on its own with testTool (agboss/testing, #262): its own container — its deps resolved, overrides replacing classes by classes — and every call in the frame of a tool call, as the agent loop runs it: the input and output validated against the DTOs, the call's executionContext.signal aborting after timeout (default the tool's own timeout) on the runner's clock, SpendRecorder into the call's spend. A failure rejects as the tool threw it (Testing → Unit runners):

import { override, testTool } from "agboss/testing";
import { expect, test } from "vitest";

@Injectable()
export class TwoIssuesApi extends IssuesApi {
  override search(): Promise<{ title: string }[]> {
    return Promise.resolve([{ title: "a" }, { title: "b" }]);
  }
}

test("counts the issues the API found", async () => {
  const countIssues = testTool(CountIssues, { overrides: [override(IssuesApi, TwoIssuesApi)] });

  const { output } = await countIssues({ query: "login" });

  expect(output).toEqual({ summary: "2 issues" });
});

In a workflow test the tool's dependencies are replaced with mockOf(Service), and app.tool(CountIssues).invoke(input) runs the tool alone with the app's real (or mocked) dependencies, in an execution of its own (its ExecutionContext reads step.callId "tool-slice") (Testing).

Clone this wiki locally