Tools
A tool is a function in your workflow that an agent calls while it works, and reads what it returns. Use one when the agent should hand you something as it goes rather than all at the end, when it needs data only the workflow holds, or when a change should go through your code, such as closing a ticket with a token the agent never sees.
A small example
export const Finding = typed(
Type.Object({
file: Type.String({ minLength: 1 }),
line: Type.Integer({ minimum: 1 }),
problem: Type.String({ minLength: 1 }),
}),
);
export type Finding = Type.Static<typeof Finding>;
export default defineWorkflow({
meta: {
name: "dead-code",
description: "An agent records each finding through a tool.",
},
async run(wf) {
const findings: Finding[] = [];
const reviewer = await wf.agents.open({
key: "reviewer",
runtime: "codex",
tools: {
add_finding: tool({
description: "Record one finding as soon as you are sure of it.",
input: Finding,
handler: (finding) => {
if (
findings.some(
(f) => f.file === finding.file && f.line === finding.line,
)
) {
throw new Error(
`${finding.file}:${finding.line} is already recorded`,
);
}
findings.push(finding);
return `Recorded; ${findings.length} so far.`;
},
}),
list_findings: tool({
description: "The findings recorded so far, one per line.",
handler: () =>
findings
.map((f) => `${f.file}:${f.line} ${f.problem}`)
.join("\n") || "None yet.",
}),
},
});
const { outcome } = await reviewer.run({
prompt:
"Find dead code in src/. Record each finding with add_finding as you go.",
timeout: "20m",
});
// What the agent recorded is kept, however its turn ended.
return {
findings,
ended: outcome.answered ? "answered" : outcome.reason,
};
},
});The reviewer gets two tools. Each finding it records lands in findings the moment it calls add_finding, so the workflow keeps every one of them even if the turn ends timed-out. The turn itself needs no schema: the findings came in through the tool.
What the agent runs
Every turn's prompt teaches the agent its tools, after how to answer. For the workflow above the block reads:
You have tools the workflow runs for you. Call one with:
/…/wfa tool <name> --<option> '<value>' ...
Put every value in single quotes; a ' inside a value is written '\''.
- add_finding: Record one finding as soon as you are sure of it.
/…/wfa tool add_finding --file <text, at least 1 character> --line <integer, at least 1> --problem <text, at least 1 character>
- list_findings: The findings recorded so far, one per line.
/…/wfa tool list_findingsThe agent runs wfa in its shell, the same launcher it answers with (see How it works):
/…/wfa tool add_finding --file 'src/report.ts' --line '41' --problem 'formatDate is never called'A small input is taught as flags, as here, by the same rule as a turn's typed answer. Any other input, a list or a nested object, is taught as its JSON Schema, and the agent sends JSON on standard input:
/…/wfa tool label_item <<'WF_JSON'
{"id": "b2", "labels": ["ops", "cron"]}
WF_JSONA tool without input is called by its name alone.
What the handler returns is what the agent reads on standard output: a string as it is, any other JSON value as JSON, nothing as nothing. wfa tool exits 0 with the result, and nonzero with what went wrong on standard error; wfa tool lists its exit and refusal codes.
Defining one
tool()types the handler frominput.inputis an object schema, wrapped intyped(...)as a turn'sschemais. Withoutinput, the handler is given{}.descriptionis what the agent reads to decide when to call it. Say what it is for and when to use it.- The handler is given the checked input and the call:
call.id, unique in the run;call.agent, the key of the agent that called, a fork's own; andcall.signal, aborted when the call's time is up or its turn ends. - It runs in your workflow's process, with your workflow's authority, and can do anything your code can: write a file, call an API, run another agent.
- Each call, its input, and its result or error go in the run's record, and
awf logsshows them.
Errors
- Input that doesn't fit the schema is refused before the handler runs. The agent is told what was wrong and calls again; it never reaches your code.
- A throw fails the call, not the run. The agent gets the error's message, so write one that says what to do next, as
"src/a.ts:3 is already recorded"does. - A result that isn't JSON (a
Date, a class instance) fails the call.tool()rejects such a handler at compile time. - A call outside a turn, or after its answer is accepted, is refused: a tool is callable only while the agent works on a turn.
- A result over 64 KiB as JSON fails the call. Return a summary or a path, not a whole file.
Time
The agent waits while the handler runs, so keep it short.
- A handler has 30 seconds by default.
timeoutsets each call's budget,"2m";timeout: falseleaves only the turn's deadline. The turn's deadline always bounds it. - When the time is up, or the turn ends,
call.signalaborts. A handler that ignores it gets 5 more seconds, then its call ends without it, and the agent is told it may have run. - An agent makes one call at a time. A second call while one runs is refused, and the agent calls again once the first answers.
- While a call runs, the agent can't answer its turn. Its
wfa resultandwfa waitingare refused until the call has answered. - The harness may stop waiting first. Each harness moves a long shell command to the background or hands it back after its own limit, codex's the shortest at 10 seconds;
wfa toolhas each one's. An agent that then calls again with the same input while the call runs is given its answer; a call made after it ended runs the handler again. So make a handler that can take longer than 10 seconds safe to run twice.
What a handler can't do
- Give work to the agent that called it. That agent is in a turn, waiting for the handler, so
run,compact,set,forkandcloseon it throw. Another agent can be run. - Wait for an event.
wf.receive(),wf.raceandwf.selectthrow in a handler.
Limits
An agent holds at most 16 tools, each named in lowercase letters, digits and _. These and the other limits on a tool are checked when the agent opens, so a mistake fails there and not mid-turn; wfa tool lists them all.
Forks, reopening and messages
- A fork has its parent's tools unless its spec gives its own;
tools: {}gives none. - Opening the same key again with tools must give the same names, descriptions and schemas; the new handlers replace the old. Left out, the agent keeps what it has.
- An agent can call its tools when a message wakes it, not only in a turn you gave it.
Testing a tool
In awf test, a scripted turn calls a tool with turn.tool(name, input), through the same checks and handler a real call goes through, and gets back the result, the failure or the refusal:
import { expect, test } from "bun:test";
import { answer, testWorkflow } from "agentswf/testing";
import deadCode from "./tools";
test("each finding the agent records reaches the workflow, and it can read them back", async () => {
const run = await testWorkflow(deadCode, {
agents: {
reviewer: answer(async (turn) => {
expect(
await turn.tool("add_finding", {
file: "src/a.ts",
line: 12,
problem: "unused export",
}),
).toEqual({ kind: "result", output: "Recorded; 1 so far." });
expect(await turn.tool("list_findings")).toEqual({
kind: "result",
output: "src/a.ts:12 unused export",
});
return "One finding.";
}),
},
});
expect(run.value).toEqual({
findings: [{ file: "src/a.ts", line: 12, problem: "unused export" }],
ended: "answered",
});
});
test("input that doesn't fit is refused before the handler runs; a throw fails only the call", async () => {
const seen: unknown[] = [];
const run = await testWorkflow(deadCode, {
agents: {
reviewer: answer(async (turn) => {
seen.push(
await turn.tool("add_finding", {
file: "src/a.ts",
line: 0,
problem: "x",
}),
);
await turn.tool("add_finding", {
file: "src/a.ts",
line: 3,
problem: "unused",
});
seen.push(
await turn.tool("add_finding", {
file: "src/a.ts",
line: 3,
problem: "again",
}),
);
return "Done.";
}),
},
});
expect(seen).toEqual([
{
kind: "refused",
code: "invalid-input",
error: expect.stringContaining("line"),
},
{
kind: "failed",
outcome: "failed",
error: "src/a.ts:3 is already recorded",
},
]);
expect(run.value.findings).toHaveLength(1);
expect(run.value.ended).toBe("answered");
});turn.tool has the details.
See also
- Tools in the workflow API
examples/tools: an agent triages a backlog through three tools, one taught as JSON, with its tests- Agents: opening an agent and giving it turns