Guides
Defining and handling tools
Tools are where a model's intent becomes your code's decision. Define them once per flow, expose them per step, and always return a transition.
Use this when adding a capability the model can invoke: capturing validated input, calling a backend, uploading a file, ending a conversation. The mechanics are small, but three separate concerns are easy to conflate.
Definition, exposure and dispatch are three different things
| Concern | Mechanism | Scope |
|---|---|---|
| Definition | defineTool() on a step or on the flow |
Flow-wide registry, built once at bootstrap |
| Exposure | @Tool-decorated methods plus names in useTool() |
Per step, per model call |
| Dispatch | The @Tool-decorated method, or a method whose name equals the tool name |
Per step |
A tool that is defined but never exposed is invisible to every model call. A tool that is
exposed but has no handler produces a session warning and an empty-success tool result.
@Tool covers exposure and dispatch together, which is why it is the preferred form.
Define once per flow
defineTool() returns ToolType[]. The schema must be a Zod object.
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"),
}),
},
];
}
Definitions from every registered step and from Flow.defineTool() are merged into one
registry. Tool names must be unique across the entire flow. A collision throws at
bootstrap, before any model call:
Duplicate tool 'user_name' registered in flow 'BasicFlow'.
Put a definition on Flow when several steps need the same name and schema; put it on the
step when only that step uses it. There is no way to scope two different schemas to the same
name in one flow — rename one of them.
Writing schemas the model can follow
The schema is the runtime contract and, in practice, most of the instruction. Use
.describe() on every field; providers surface those descriptions to the model.
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"),
}),
Handlers still receive Record<string, any>. Constraints in the schema guide the model, but
your handler is the boundary that decides. DOBStep re-checks that the three numbers form a
real calendar date after the schema has already bounded each one.
@Tool and @Tool(“alias”)
Use bare @Tool when the method name equals the tool name:
@Tool
protected async user_name(args: Record<string, any>): Promise<ToolResponseType> {
// ...
}
Use the alias form when they differ — for example when the tool name is snake_case for the model but the method follows your codebase style:
@Tool("capture_name")
protected async saveName(args: Record<string, any>): Promise<ToolResponseType> {
// ...
}
Decorators register against the prototype and are resolved by walking the prototype chain, so handlers are inherited. A subclass can reuse a parent’s handler or override the method to replace it. Declaring two different methods for the same tool name on one class throws:
Tool 'capture_name' is already handled by method 'saveName'.
useTool() and undecorated handlers
useTool() returns tool names this step should expose without a decorator — typically a tool
defined by Flow.defineTool() and shared:
public useTool(): string[] {
return ["lookup_customer"];
}
Dispatch then falls back to a method on the step whose name equals the tool name, even
without a decorator. This is the legacy path; prefer @Tool because it keeps exposure and
dispatch in one place. The exposed set is the union of useTool() and every decorated tool
name, deduplicated.
Return a semantic transition
A handler normally returns a ToolResponseType: a step class, a registered step-name string,
or a builder from go() / stay() / direct().
@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.");
}
this.saveState({ name });
return go(DOBStep);
}
| Builder | Effect, applied after the destination is activated |
|---|---|
withToolFeedback(text) |
Text returned to the model as the tool result |
withState(json) |
Saved on the destination step |
withPrompt(text) |
Saved as _prompt on the destination step |
withMessage(message) |
Appended after the tool-result message |
withContentType(type) |
Sets the destination’s response content type |
return go(TerminateSessionStep)
.withPrompt(DemoPrompt.FromAddressEnd)
.withState({ fromAddress: 5 });
stay(feedback?) resolves the currently executing step and returns go(ThatStep) with the
feedback attached. Without an argument it uses the framework’s standard input validated
message. It is valid only inside a tool handler, because it reads the tool-response
execution context.
direct(content) returns an AI message straight to the caller and skips a further model
call, while keeping the current step active:
return direct(`${table}\nAnother comparison or ready to book?`);
this.flow.markCompleted();
return direct(args?.json).withContentType(HttpContentType.Json);
Return data from a parallel child
Inside a child launched by runSteps(), a tool handler can return
directResult(value). It ends that child after the tool call—without another model call or a
cursor transition—and places a JSON value at the corresponding batch.fulfilled[*].output.
It is intentionally only for runSteps() children; go(), stay(), and direct() still
throw there because they would attempt a transition in the parent’s execution frame.
@Tool
protected async lookup_inventory(): Promise<ToolResponseType> {
const item = await this.inventory.lookup(this.getParallelInvocation().params.sku);
this.saveState({ checked: true });
return directResult({ sku: item.sku, available: item.available });
}
// Parent step
const batch = await this.runSteps([{ step: InventoryLookupStep, params: { sku } }]);
const result = batch.fulfilled[0]?.output;
value must be JSON-compatible: a string, number, boolean, null, array, or object. It is
not a replacement for direct(), which remains the normal user-facing response builder.
A handler that returns null, undefined, or an object without a step leaves the runner with nothing to apply. For single-tool handlers this produces no tool-result message; for group @Tools handlers it throws. Use stay() when the answer is "remain here".
Missing handlers and hallucinated tools
The runner distinguishes three cases when a model calls a tool:
| Case | Behaviour |
|---|---|
| Exposed and handled | Handler runs; its transition and builders are applied |
| Exposed, no handler found | Session warning missing tool handler: x; an empty-success tool result is returned |
| Not exposed by this step | Session warning hallucinated tool: x; the model is told the tool does not exist |
Neither case aborts the turn. Check the session document’s warn array when a model appears
to call a tool that “does nothing”.
Failure modes
| Symptom | Cause |
|---|---|
Duplicate tool 'x' registered in flow 'Y'. |
Two defineTool() results share a name |
Tool 'x' is already handled by method 'y'. |
Two decorated methods claim the same tool name |
Tool 'x' is not defined in flow 'Y'. |
useTool() names a tool that no defineTool() declared |
@Tool can only decorate methods. |
The decorator was applied to a property or a static member |
| Model never calls the tool | It is defined but not exposed — no @Tool, and not in useTool() |
| Handler runs but nothing changes | It returned a non-routable value; use stay() or go() |
stay() throws outside a handler |
It requires the tool-response execution context |
Next: Multi-tool batch handlers for a single response containing several calls, and Structured output and responses for the no-tool path. Reference: @Tool and @Tools and go() / stay() / direct().