BasicFlow tutorial
4. Tools and Zod
A tool is a Zod schema the model may call and a decorated method that decides what happens next. The schema shapes the arguments; the handler owns the decision.
Prompt text is a request. A tool call is a typed function invocation with a validated argument object. The difference matters because only one of the two can be relied on. BasicFlow’s collection steps are built on the second, and their prompts exist mainly to tell the model to stop reasoning about the rule and call the tool.
What you will build
- A tool definition with
defineTool()returningToolType[]. - A Zod object schema with
.describe()on every field. - A handler bound to the tool with the
@Tooldecorator. - A clear split between what the prompt says and what the code enforces.
Defining a tool
From pico-demo/src/myflow/basic-flow/name-step.ts:
public defineTool(): ToolType[] {
return [
{
name: "user_name",
description: "Capture name of user",
schema: z.object({
name: z.string().min(3).describe("Complete first and last name"),
}),
},
];
}
ToolType is exactly three fields:
export type ToolType = {
name: string;
description: string;
schema: z.ZodObject;
};
The schema must be a Zod object, not a bare scalar — providers expect a named
argument bag. defineTool() is called once per request during composeTool(), and
every step’s definitions are merged into one flow-wide registry keyed by name.
Duplicate names throw at bootstrap.
DOBStep shows a richer schema:
public defineTool(): ToolType[] {
return [
{
name: "dob",
description:
"Capture a valid date of birth after interpreting numeric slash dates as month/day/year.",
schema: z.object({
year: z.number().int().min(1900).max(2100).describe("Four-digit year"),
month: z.number().int().min(1).max(12).describe("Calendar month, 1 through 12"),
day: z.number().int().min(1).max(31).describe("Calendar day, 1 through 31"),
}),
},
];
}
Why .describe() on every field
The Zod schema is serialised into the provider’s function-calling payload, and
.describe() becomes the field description the model reads. It is not documentation
for your team — it is part of the prompt. "Four-digit year" and
"Calendar month, 1 through 12" are there to stop the model sending "2000-01-01" as
a year or a zero-based month.
The same reasoning applies to description on the tool itself.
"Capture a valid date of birth after interpreting numeric slash dates as month/day/year" tells the model when to call it and how to interpret ambiguous input.
A description of "dob tool" would push that decision back into the prose prompt,
where it is weaker.
Note what the schema does not do. min(1900).max(2100) on the year and
min(1).max(31) on the day are shape constraints; they will happily accept 31
February. The real calendar check lives in the handler.
Handling the call
@Tool
protected async user_name(
args: Record<string, any>,
): Promise<ToolResponseType> {
const name = typeof args?.name === "string" ? args.name.trim() : "";
if (name.toLowerCase() === "john doe") {
return stay("Cannot accept John Doe, please choose a different name.");
} else {
this.saveState({ name });
// ... nested work, covered in lesson 12
return go(DOBStep);
}
}
@Tool with no argument binds the handler to the tool whose name matches the method
name. @Tool("external_name") binds to a different name when the method cannot share
it.
The decorator does two jobs, and this is the part worth remembering:
- Selection. It exposes that registered tool to this step’s model call.
getUsedToolNames()unionsuseTool()with every decorated tool name, andobtainTools()resolves each through the flow registry. A tool defined by one step is only offered to the model by the steps that decorate a handler for it. - Dispatch. It registers the runtime handler, so
invokeToolHandler(call)can find it.
That is why WeatherStep, NameStep, DOBStep, and AddressStep all have a
terminate_session handler but none of them defines the tool. The definition comes
from the framework’s TerminateSessionStep; the decorator is what puts it on their
model’s menu.
@Tool
protected async terminate_session(): Promise<ToolResponseType> {
return go(TerminateSessionStep).withPrompt(DemoPrompt.AbruptEnd);
}
Handlers are looked up on the prototype chain, so decorated handlers are inherited and a subclass can override one.
Tools inside a parallelized Step
@Tool works inside a runSteps() child exactly as it does in a normal conversational Step.
The child model can select the tool, and the handler can validate arguments, call a backend, and
write only that child’s state. ConcurStep1 uses this pattern in the parallel lesson:
@Tool
protected async complete_concurrent_step1(): Promise<ToolResponseType> {
const result = { completed: true };
this.saveState({ concurStep1: result });
return directResult(result);
}
directResult(result) is specifically the return channel for a parallel child. It ends that
child after the tool call and makes result available as the matching
batch.fulfilled[*].output value. It neither routes the Flow nor asks the model for a second
response.
The ordinary builders have different jobs: go(), stay(), and direct() are valid in normal
tool handlers, but throw from a parallel child because they would alter the parent’s execution
frame. Use directResult() only from a tool handler in runSteps() and give it JSON-compatible
data. See Parallel children and tools: runSteps()
for the complete ConcurStep1 lifecycle.
The handler owns the decision
Look at what DOBStep does after the schema has already passed:
@Tool
protected async dob(args: Record<string, any>): Promise<ToolResponseType> {
const date = new Date(Date.UTC(args.year, args.month - 1, args.day));
const isValidDate =
date.getUTCFullYear() === args.year &&
date.getUTCMonth() === args.month - 1 &&
date.getUTCDate() === args.day;
if (!isValidDate) {
return stay(
"That date is not valid. Ask for a valid date of birth in M/D/YYYY format.",
);
}
this.saveState({ year: args?.year, month: args?.month, day: args?.day });
return go(AddressStep);
}
The round-trip through Date.UTC catches 31 February, which no Zod range can. The
handler then either advances or hands corrective text back to the model. The model is
never asked to decide whether the date is real.
NameStep’s prompt makes the division of labour explicit:
Treat any plausible first-and-last-name response as collected input and immediately
call 'user_name' with the complete name. Do not validate or reject a name in prose
before calling the tool; the tool owns validation.
If 'user_name' rejects the name, clearly repeat the tool's reason and ask for a
different full name. Remain in this step.
And the shared role file, prompt/role.md, closes the loop:
- Never invent a successful tool result. Let the tool handler validate the user's
input and follow its response.
Why it is written this way
Two properties fall out of putting the decision in the handler.
It is testable without a model. The scripted-model contract run in
test/basic-flow/ replaces the provider entirely and still exercises every branch of
user_name and dob, because those branches are ordinary TypeScript reachable from a
tool call payload.
It cannot be talked around. A rule in prompt prose is advisory: a sufficiently
insistent user, or a model having a bad day, will route around it. A rule in the
handler is the only path to the state write. stay("Cannot accept John Doe...") is not
a suggestion to the model about what to say — it is the tool result, and the step did
not advance.
The prompt still matters, but its job is narrower than people expect. It tells the model when to call the tool and how to phrase a rejection, not whether the input is acceptable.
Common mistakes
- Putting the business rule in the prompt and a rubber-stamp in the handler. The handler is the runtime boundary. If a rule is not enforced there, it is not enforced.
- Omitting
.describe(). The model sees the field name and type only, and argument quality drops immediately for anything ambiguous likemonthorcity. - Defining a tool twice. Definitions are flow-wide. Two steps defining
addressfail at bootstrap withDuplicate tool 'address' registered in flow 'BasicFlow'. - Expecting a handler to run for a tool it did not decorate. A step only offers
the tools it decorates or lists in
useTool(). Defining a tool indefineTool()registers it flow-wide but does not put it on that step’s menu. - Trusting Zod for domain validity.
min(1).max(31)accepts 31 February. Range constraints shape the payload; they do not check the domain.
Next
Both handlers above end in stay() or go().
5. Routing with go() and stay() explains what those
actually do.