Repository navigation
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.
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).
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.
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.
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@McpTooland a knowledge base'ssearch-<rag>-toolalike. 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'sconfigHash. 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.Contextis bounded the same way: each knowledge base's block in the agent's input message by the agent'smaxResultSize(its workflow'sdefaults.tools, else 128 KB). -
onToolEndcarriesisResultTruncated(updatethen ends with the marker, Observability);agboss describeprints each tool's bound and where it came from —maxResultSize 64 KB (desk-application),256 KB (own),128 KB (built-in)— anddescribe --jsonhas it in bytes:schemas.tools[name].maxResultSizeand each agent'smaxResultSize(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(…).runreturns the tool's full output — the bound is what a model reads, not what a tool returns.
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.
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/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 {}-
toolslists 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 withLimitExceededError; 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.signalis the call's (#280 S45): the execution's signal (cancel included) combined with a timer of the tool'stimeout(default 30 s), cleared when the call ends — sofetch(url, { signal: this.executionContext.signal })stops on a cancel and on the call's own timeout, while the execution goes on (the model readsTool error: timed out after 10 s). -
executionContext.step.callIdis the call's id — the same when a call interrupted by a crash runs again — andexecutionContext.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.signalwithExecutionLeaseLostErrorand 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.
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.
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.
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
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: XDtofor a tool approval (the question isToolCallApprovalAskDtowitharguments: XDto, the answerToolCallApprovalDecisionDtowithoverrideArguments?: XDto), orask/answerfor a workflow question.answeredByis required —PauseAnswerer.ThreadOwnerorPauseAnswerer.Channel, no default. A tool whoseinputis not its role'sapproves(or that names a question role) fails assembly with[channel.wrong-tool]; onereplyInterpreterclass in two roles with[channel.shared-interpreter].replyInterpreteris a@ReplyInterpreterclass (#267 PR 3) that reads a free-textapp.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 aReplyInterpretation(Pauses → free text). -
The implementation is a
@Channelthatextendsthe role, listed in@Application({ providers })— found by inheritance, like any contract: none →[di.missing-implementation], two →[di.ambiguous-implementation]; a@Channelin a workflow'sproviders→[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:askis{ pauseId, value }(the value typed by the generatedChannelRolestable —agboss check;ChannelQuestionOf<Role>is the question,ChannelAnswerOf<Role>the answerdecidedtakes; the execution throughdeps).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). Asendthat returns anything else fails the step. -
Who answers (#267 PR 2): the role's
answeredBy. APauseAnswerer.ThreadOwnerpause is answered with the thread'sownerIdand itspauseIdis listed inresult.pending; aPauseAnswerer.Channelpause — the reply comes through the channel's own system — is answered withapp.resume({ pauseId: ask.pauseId, decision }, { answeredBy: "manager-17" }), its id never listed (it is the right to answer). ThepauseIdis made once per pause, before the firstsend: a retriedsendgets 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.
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).
AGBoss
- Home
- Delivery Pipeline
- Architecture
- Configuration
- Environments
- Workflow — the paths
- Path scope
- Workflow as a step
- Parallel — quorums and arbiters
- Routing patterns
- Rollback — declared compensation
- Routers
- Guards
- CLI — agboss
- App — @Application and createApp
- Several instances
- Health checks
- Context and Authorization
- Testing
- The test split
- Suites and Comparisons
- Components
- Naming
- Standard DTOs
- Examples: job-scout, flow-patterns
- Agents — the loop
- Model providers
- Tools
- Knowledge bases (RAG)
- FinOps
- Tracing
- Observability
- Terns and evaluation
- Profiles and comparisons
- Pauses — pending, resume by id
- Pause seam
- Decisions (ADR)