InvoiceFlow tutorial
2. A step with no tools
NoToolStep has thirty lines, no tool definitions, and no lifecycle overrides. It uses a structured response as an application-controlled alternative to model-selected tool calling: prompt, receive, validate, and route in onResponse().
Tools are one way a model asks your application to do something: the model must first decide to emit a tool call, and only then does PicoFlow dispatch the handler. That selection is not equally reliable across providers and model sizes. A smaller model can follow a strong instruction and still answer in prose instead of emitting the requested tool call, which means the handler never runs.
NoToolStep demonstrates the important alternative. Ask the model for a
small, explicit structure, receive that structure as the normal model reply,
and let onResponse() become the response handler: parse or accept the object,
validate it, save it, run application logic if needed, and return the next
transition. There is no tool-selection decision for the model to miss.
This is not merely a way to write a step with fewer lines. It is a different
control channel. Instead of model chooses a tool → tool handler runs, the
application owns the path: model returns data → onResponse() decides what
that data is allowed to do. For stages whose real output is a command,
classification, extraction, or routing decision, this can replace tool calling
entirely.
The goal
- Write a step with only
getPrompt()andonResponse(). - Route from
onResponse()by returning a transition instead of a string. - Substitute an object into a prompt with
Prompt.replace2(), and know how it differs fromPrompt.replace(). - Parse a model reply defensively with
StringUtil.parseJson. - Recognise when a structured response handled by
onResponse()is more reliable than waiting for a model-selected tool call.
The whole step
pico-demo/src/myflow/invoice-flow/no-tool-step.ts, complete:
const PromptTemplate = Prompt.file("prompt/nt-prompt.md");
export class NoToolStep extends Step {
constructor(flow: Flow) {
super(flow);
}
public getPrompt(): string {
const randomZip = (): string => {
return Math.random() < 0.5 ? "97006" : "97005";
};
const prompt = Prompt.replace2(PromptTemplate, {
internal_address: {
street: "123 Main St",
city: "Beaverton",
state: "OR",
zip: randomZip(),
},
});
return prompt;
}
public async onResponse(
llmResult: string | object,
): Promise<LastResponseType> {
const parsedResult = StringUtil.parseJson<JsonValue>(llmResult as string);
this.saveState({ current_date: parsedResult });
return go(ExtractInvoiceStep).withState({
from_previous: parsedResult as JsonValue,
});
}
}
No defineTool(). No onEnter(), no onCrossing(), no useMemory(...). The
base class supplies all of them: the default onCrossing() injects a "Start"
message when the step is entered with nothing to say, and the constructor sets
the memory namespace to the class name.
The prompt
prompt/nt-prompt.md:
#Output Schema:
```json
current_date={
year: "number",
month: "number",
day: "number",
},
```
## Variables:
`internal_address` = {{internal_address}};
## Instruction:
- if the 'internal_address.zip' ===97006, set `current_date` to today's date,
else set `current_date` to 2023-01-01;
- output `current_date` in JSON format
The step exists to demonstrate mechanics, and the mechanic it demonstrates is a
branch the model has to evaluate: the zip code is randomised in
getPrompt(), so roughly half of all runs produce today’s date and the other
half produce 2023-01-01. Run the flow twice and you can see the substitution
actually reaching the model.
The important technique: response-driven structured work
The prompt is asking for a data structure, not asking the model to call a tool:
if the 'internal_address.zip' ===97006, set 'current_date' to today's date;
else set it to 2023-01-01;
output 'current_date' in JSON format
That distinction matters whenever a workflow depends on a model reliably
submitting a value. With a tool-based design, the model has to select the tool,
construct valid arguments, and emit the provider’s tool-call message. If it
does not select the tool, toolHandle-style application code is never reached.
With a response-driven design, every ordinary model completion reaches
onResponse():
public async onResponse(
llmResult: string | object,
): Promise<LastResponseType> {
const value =
typeof llmResult === "string"
? StringUtil.parseJson<JsonValue>(llmResult)
: (llmResult as JsonValue);
if (!value || typeof value !== "object" || Array.isArray(value)) {
return go(NoToolStep).withPrompt(
"Return only the requested JSON object with year, month, and day.",
);
}
// Validate domain fields here before making a side effect or transition.
this.saveState({ current_date: value });
return go(ExtractInvoiceStep).withState({ from_previous: value });
}
The application, rather than the model, owns the decision after the response
arrives. onResponse() can reject malformed data, issue a corrective prompt,
call a service directly, save state, choose a step, or return a final response.
That makes it analogous to a tool handler in responsibility, while avoiding the
unreliable tool selection step.
There are two versions of this technique in the examples:
| Response contract | What arrives in onResponse() |
Trade-off |
|---|---|---|
Prompted JSON + StringUtil.parseJson |
Text that the application parses | Works broadly, but syntax and field correctness must be validated by application code. This is the exact NoToolStep pattern. |
structOutputSchema() + onResponse() |
A parsed object | The provider enforces more of the shape, while onResponse() still owns domain validation, side effects, and routing. See BasicFlow’s structured-output lesson. |
The pattern is strongest when the model’s job is to produce one structured
decision or payload. It does not make external work happen by itself: if the
step must fetch a file, update an order, or charge a card, onResponse() must
call the application service and handle its result, or a tool handler remains a
good boundary. The key question is whether you need the model to choose an
operation or only to return the data that your code will interpret.
For smaller models, this distinction is often decisive. A prompt can tell the model exactly what structure to return, and the application can make the final decision even when the model fails to follow the ideal format. A tool-only design has no equivalent recovery path when the model simply answers without calling the tool.
replace2 against replace
The two substitution helpers look interchangeable and are not:
public static replace(
template: string,
values: Record<string, string>,
): string {
return template.replace(/{{(.*?)}}/g, (match, key) => {
return values[key] || match;
});
}
public static replace2(
template: string,
values: Record<string, any>,
): string {
return template.replace(/{{(.*?)}}/g, (match, key: string) => {
const value = values[key.trim()];
if (value === undefined) {
return match;
}
return typeof value === 'string' ? value : JSON.stringify(value);
});
}
replace |
replace2 |
|
|---|---|---|
| Value type | string only |
any |
| Non-strings | not handled — you stringify first | JSON.stringifyd for you |
| Missing key | falls back on ||, so "" and 0 also fall back |
falls back only on undefined |
| Key whitespace | not trimmed, {{ key }} misses |
trimmed, so spacing is tolerated |
NoToolStep passes a nested object, so replace2 is the only one that works
without an explicit JSON.stringify at the call site. HotelFlow’s steps pass
pre-stringified JSON and use replace. Prefer replace2 for new code; its
undefined check and key trimming remove two easy mistakes.
onResponse and the response type
onResponse() is called with the model’s text once a turn produces no tool
call. The base implementation just passes it through:
public async onResponse(
llmResult: string | object,
): Promise<LastResponseType> {
if (typeof llmResult === 'object') {
return JSON.stringify(llmResult);
}
return llmResult;
}
LastResponseType is the union that makes routing from onResponse()
possible:
export type LastResponseType =
| {
step: StepTarget;
message?: MessageTypes;
prompt?: string;
state?: JsonObject;
contentType?: HttpContentType;
}
| StepTarget;
So an override has three options:
| Return | Effect |
|---|---|
a string |
That text becomes the reply and is appended to the step’s history |
a Step class or step name |
The cursor moves and the destination runs immediately |
a go(...) builder |
Same, with state, a prompt, a message, or a content type attached |
NoToolStep returns the third form. The runner applies it exactly as it would
a tool handler’s transition:
} else if (llmResponse && 'step' in llmResponse) {
let step: Step;
if (typeof llmResponse.step === 'string') {
step = await flow.gotoByName(llmResponse.step);
} else {
step = await flow.goto(llmResponse.step);
}
...
return await LlmRunner.send(flow, priorStep);
}
That trailing send is why the whole flow completes in one HTTP request:
ExtractInvoiceStep starts running immediately, inside the same turn.
stay() resolves the current step from the tool-handler execution scope, which onResponse() is not inside. Calling it there throws. To loop, return the current step class or a go(ThisStep) builder explicitly.
Parsing the reply
const parsedResult = StringUtil.parseJson<JsonValue>(llmResult as string);
The prompt asked for “JSON format”, which in practice often arrives wrapped in
a Markdown fence. StringUtil.parseJson strips the fence before parsing and
returns null instead of throwing:
public static parseJson<T = unknown>(response: string): T | null {
// Remove markdown code fences if present
const cleaned = response
.trim()
.replace(/^```json\s*/i, '')
.replace(/^```\s*/i, '')
.replace(/\s*```$/, '');
try {
return JSON.parse(cleaned) as T;
} catch (_error) {
return null;
}
}
null is a real possibility, and NoToolStep does not check for it — it saves
and forwards whatever came back. In production, a null here should either
route back to this step with corrective instructions or fail the request
loudly; silently forwarding null as state is the sort of defect that only
shows up much further downstream.
Two writes, one of them unused
this.saveState({ current_date: parsedResult });
return go(ExtractInvoiceStep).withState({
from_previous: parsedResult as JsonValue,
});
The first writes to NoToolStep’s own state; the second writes to
ExtractInvoiceStep’s. It is a compact demonstration of the two directions:
saveState is local, .withState is for the destination.
ExtractInvoiceStep does not reference from_previous anywhere. The value is carried across the transition and persisted, but nothing consumes it. It is here to show the mechanism, not because the extraction needs a date.
Why it is written this way
A step earns a tool when the model must choose an application operation and the operation benefits from the tool-call protocol. A step that only needs the model’s own output — or needs the application to interpret a structured model decision — does not have to expose a tool at all. Adding a tool in that case introduces another model decision and gives smaller models another way to fail.
The trade is real, though. Without a provider-enforced schema the model can
return prose, a fenced object, or an apology, and parseJson will hand you
null for two of the three. Treat parsing and domain validation as part of the
response-handler contract. Use a prompted response when portability and
application control matter most; use structOutputSchema() when the provider’s
structured-output support is available; use a tool when the model must invoke a
real application operation through the tool protocol.
Common mistakes
- Calling
stay()fromonResponse(). There is no tool execution scope; it throws. - Returning a string when you meant to route. A string ends the turn as the reply, and the cursor does not move.
- Ignoring a
nullfromparseJson. It is the documented failure result, not an edge case. - Using
Prompt.replacewith a non-string value. It writes[object Object]into the prompt. Usereplace2or stringify first. - Assuming a step without
defineTool()cannot route. Routing is a return value, not a tool feature.
Next
3. Example-as-schema prompting looks at the extraction prompt, which pins a fifty-field output shape without declaring a schema at all.