Guides
Structured output and responses
What happens when a model responds without calling a tool — schema-constrained output, rewriting or routing in onResponse, and the retry predicate whose polarity is the opposite of what you expect.
Use this when a step’s job is to produce data rather than to converse: an extraction, a
classification, a JSON payload for a caller. It is also the page to read before writing
checkResponse(), whose return value means the opposite of what most people assume.
The three result paths
Every model turn ends on exactly one of these:
| Path | Trigger | Handled by |
|---|---|---|
| Tool call | The response contains tool_calls |
@Tool / @Tools handler, then the transition machinery |
| Plain response | No tool calls | onResponse(result) |
| Direct message | A handler returned direct(...) or .withMessage(...) with a direct AI message |
Returned to the caller with no further model call |
The first is covered in Defining and handling tools. This page covers the other two.
Structured output with structOutputSchema()
Return a schema and PicoFlow binds it to the model with withStructuredOutput(...) before
the call:
public structOutputSchema(): object {
return z.object({
title: z.string().describe("Movie title"),
genre: z.string().describe("Main genre"),
releaseYear: z.number().describe("Release year"),
rating: z.number().min(0).max(10).describe("Rating from 0 to 10"),
summary: z.string().describe("Short plot summary"),
});
}
The default is null, meaning free-form text.
Two consequences worth planning for:
onResponse()now receives the parsed object, not a string. The runner only converts to text when the response has acontentproperty, which a structured result does not.- Providers frequently omit usage metadata for structured-output calls, so those tokens may
not appear in the session document’s
tokenstotals.
The runner binds tools first and then applies the structured-output wrapper. A step that both exposes tools and constrains output is asking the provider for two mutually exclusive response shapes. Pick one per step: use tools for decisions and side effects, structured output for extraction.
The alternative — used by FavoritesStep and NoToolStep — is to describe the shape in the
prompt, often by interpolating an example JSON file, and parse the text yourself with
StringUtil.parseJson. That works with every provider and keeps the response inspectable.
onResponse() and what you may return
public async onResponse(llmResult: string | object): Promise<LastResponseType>
The base implementation stringifies objects and returns strings unchanged. LastResponseType
allows three shapes:
| Return | Effect |
|---|---|
string |
Pushed into memory as an AIMessage and returned to the caller |
A Step class or registered step name |
The flow moves the cursor there and continues executing |
{ step, message?, prompt?, state?, contentType? } |
Moves the cursor and applies the extras before continuing |
Saving and returning:
public async onResponse(llmResult: string | object): Promise<LastResponseType> {
this.saveState({ who: llmResult as JsonValue });
return llmResult as string;
}
Parsing, validating, then routing:
public async onResponse(llmResult: string | object): Promise<LastResponseType> {
const json =
typeof llmResult === "string"
? StringUtil.parseJson<JsonValue>(llmResult)
: (llmResult as JsonValue);
if (json && typeof json === "object" && !Array.isArray(json)) {
this.saveState({ favorites: json });
return go(NameStep);
}
return typeof llmResult === "string" ? llmResult : JSON.stringify(llmResult);
}
Note the fallback. When the model returns something unusable, returning the raw text lets the user see it and the conversation continue, rather than throwing mid-turn.
Routing from onResponse()
Returning a step class from onResponse() is a real transition: Flow.goto() runs, so the
current step’s onExit() and the destination’s onEnter() and onCrossing() fire, and the
runner immediately makes another model call on the destination.
lastResponse(...) is the purpose-built builder for the object form:
return lastResponse(ExtractInvoiceStep)
.withState({ from_previous: parsedResult as JsonValue })
.withContentType(HttpContentType.Json);
go(...) is structurally compatible and is what the demo uses — NoToolStep returns
go(ExtractInvoiceStep).withState({ from_previous: parsedResult }). Prefer lastResponse()
in new code, because go()'s withToolFeedback(...) has no meaning outside a tool handler.
Each transition triggers another model call. A step whose onResponse() unconditionally routes to a step that routes back will spin until the process runs out of stack or budget. Route only on a condition that the destination will change.
checkResponse() and inverted retry semantics
public checkResponse(llmResult: string | object): boolean
false accepts the response. true asks for a retry. The default returns false.
public checkResponse(result: string | object): boolean {
// true means "this is unacceptable, call the model again"
return !looksLikeCompleteJson(result);
}
Two properties are required of the predicate:
- deterministic — it runs once per attempt, on different responses;
- side-effect free — it must not save state or mutate memory, because the response it rejected is discarded.
The declared parameter type is string | object, but the runner passes the AIMessageChunk returned by the provider — not the extracted text. Read result.content, or narrow with typeof result === "string" before treating it as text. This differs from onResponse(), which does receive extracted text for non-structured calls.
What the retry loop actually does
checkResponse() is only one input to the loop. The full sequence per attempt:
attempt 1..N (N = step retryAttempts, then Flow, then adapter fallback, default 3)
invoke the model
tally tokens
if the response has no tool calls and blank content:
pop the offending message(s) from memory
push "Follow system prompt and respond properly."
retry
else:
retry if checkResponse(response) === true
wait 500ms between attempts
exhausted -> throw: LLM call failed. Reason:<finish reason>. Error:<message>
Empty-response recovery is automatic and needs no code from you. Set retryAttempts in the
Flow or Step selection when that workflow needs a different budget:
protected configModel() {
return {
provider: "in-house",
name: "support-model",
params: {},
retryAttempts: 5,
} as const;
}
Every attempt is a billed model call. checkResponse() that rejects too eagerly triples the
cost of a step.
Direct responses and content types
A direct message ends the HTTP turn without another model call, while leaving the current step active:
this.flow.markCompleted();
return direct(args?.json).withContentType(HttpContentType.Json);
The response envelope carries contentType from the step, and the demo controller sends the
raw message with that content type whenever it is not text/plain:
if (result.contentType && result.contentType !== HttpContentType.Plain) {
return res.type(result.contentType).send(result.message);
}
return res.send(result);
So a JSON content type changes the response body shape, not just its header. Callers that
expect the standard { success, completed, message, session } envelope will break unless
they branch on the content type. Set it deliberately.
Failure modes
| Symptom | Cause |
|---|---|
| The retry loop never triggers | checkResponse() returns true for good responses and false for bad ones — the polarity is inverted |
| Every step call costs 3x | checkResponse() rejects acceptable responses |
checkResponse sees [object Object] |
It received an AIMessageChunk; read .content |
onResponse receives an object unexpectedly |
structOutputSchema() is set, so no text extraction happens |
| Token totals look low | Structured-output calls often report no usage metadata |
LLM call failed. Reason:length. |
The response was truncated; raise maxTokens in the model params |
| Infinite model calls | onResponse() routes unconditionally between two steps |
| The client cannot parse the response | A non-plain contentType changed the body from the envelope to the raw payload |
Related: Structured output, Response-driven steps, and Error handling and completion.