Concepts
Flows and steps
The two classes that carry the whole mental model: a Flow is the durable boundary and the registry, a Step is the customization boundary, and class names are persisted identifiers.
PicoFlow has exactly two abstractions you subclass. Everything else — memory, tools, persistence, routing, model resolution — hangs off one of them. Getting the division of responsibility right is most of what “learning PicoFlow” means.
Flow -> registered Step -> prompt, tools, typed state, and memory
-> go(...) / stay(...) / direct(...)
-> one versioned session document
Flow: the durable boundary
A Flow subclass declares a workflow. It owns:
| Responsibility | Expressed as |
|---|---|
| A stable registered name | The class name, or the static id |
| The default model | configModel() — abstract, you must implement it |
| The set of steps that can ever be activated | defineSteps() |
| The initial cursor | The first step from defineSteps(), or initialStep() |
| Flow-wide tool definitions | defineTool() |
| Memory container and summary policy | getMemory() in the constructor |
| Session-wide context | Populated from the first request’s config |
| Restore and migration policy | onRestoreSessionDoc(doc) |
| Batch coordination | spawnSteps() and concurrentSteps(...) |
export class HotelFlow extends Flow {
public constructor() {
super();
this.getMemory()
.setSummaryModel({ provider: "openai", name: "gpt-4o" })
.setSummaryConfig({ minMessages: 8, recentMessages: 4 })
.enableSummary("hotel-explore");
}
protected configModel() {
return { provider: "openai", name: "gpt-4o" } as const;
}
protected defineSteps(): Step[] {
return [
new ExploreStep(this).useMemory("hotel-explore").useModel({
provider: "openai",
name: "gpt-5.1",
params: { reasoning: { effort: "low" } },
}),
new PresentStep(this).useModel({
provider: "openai",
name: "gpt-4o",
params: { temperature: 0.5 },
}),
new CompareStep(this).useModel({
provider: "openai",
name: "gpt-5.1",
params: { reasoning: { effort: "low" } },
}),
new TerminateSessionStep(this).useMemory("end"),
];
}
}
That is the entire HotelFlow class. Topology, model policy, and memory policy — nothing
else. All hotel behaviour lives in the three steps.
The flow is the persistence boundary
“Durable boundary” is literal. One flow instance corresponds to exactly one session document, and that document contains exactly one flow envelope — never an array of them.
The flow object itself is rebuilt from scratch on every HTTP invocation. It is not a
long-lived service. The engine constructs it, binds the registered name, adds the request
config as context, calls init(), collects steps, loads or creates the session, and
throws the instance away when the turn ends. Anything you want to survive must be in the
session document.
PicoFlow makes each conversation an application-readable case record, rather than persistence that only the runtime can interpret. One document brings together the active stage, business state, model context, token use, execution trail, and diagnostics. Teams can inspect and query it in their ordinary database, or copy and redact it into isolated test storage for safe incident reproduction. See Session document: state, diagnosis, and replay for the model, and Operate and debug session documents for the operational workflow.
Do not do request-specific work in the Flow constructor or in init(). Both run on every single request, including restored sessions. They are for deterministic setup such as memory configuration, not for calling external systems.
defineSteps() is a registry, not a graph
defineSteps() does not describe transitions. It declares which steps exist. The transition
graph lives inside the step handlers, as go(...) targets.
This has a consequence worth internalising: conditional registration is dangerous,
conditional activation is fine. BasicFlow selects its starting step from context:
protected initialStep() {
return this.getContext<boolean>("config.isPresident")
? PresidentStep
: WeatherStep;
}
Both PresidentStep and WeatherStep are always registered. Only the choice of cursor
varies. If instead you registered a different set of steps depending on a value that can
change between turns, a restored session could find that the step named in
flow.currentStep no longer exists.
Step: the customization boundary
A Step subclass owns one cohesive part of the conversation. Its constructor takes the flow
and nothing else:
protected constructor(flow: Flow)
A conventional conversational step overrides three members and nothing more:
export class CollectNameStep extends Step {
constructor(flow: Flow) {
super(flow);
}
public getPrompt(): string {
return "Ask for a full name, then call capture_name.";
}
public defineTool(): ToolType[] {
return [{
name: "capture_name",
description: "Validate and save a full name",
schema: z.object({ name: z.string().min(1) }),
}];
}
@Tool
protected async capture_name(
args: Record<string, any>,
): Promise<ToolResponseType> {
const name = args.name.trim();
if (!name.includes(" ")) return stay("Please provide a full name.");
this.saveState({ name });
return go(NextStep);
}
}
Everything a stage needs is co-located: what the model is told, what it may call, what happens when it calls, what gets saved, and where control goes next. A developer changing name-collection behaviour opens one file.
The step is also the boundary for:
- state —
saveState/getState, persisted per step; - memory — a named conversation-history namespace, selected with
useMemory(...); - model — an optional override with
useModel(...); - content type — the HTTP content type of the response;
- lifecycle hooks —
onStart,onRestore,onEnter,onExit,onCrossing; - response handling —
onResponse,checkResponse,structOutputSchema.
See Step lifecycle for when each hook fires, and State, memory, context, transient for the data model.
One durable cursor
The flow envelope holds exactly one cursor: flow.currentStep, a step-name string. There is
no “active” flag on a step document. Flow.goto(...) — reached through go(...) in a
handler — is the only API that moves it.
Nested execution is different. runStep(ChildStep) and runSteps([...]) push in-memory
execution frames. Parallel children receive isolated snapshots and may publish only their own
successful state at the join; neither kind can move the cursor. The owner alone decides the
next durable position.
Step class names are persisted identifiers
This is the single most important operational fact about PicoFlow, and it is easy to miss because nothing in the source looks like a schema declaration.
The BasicFlow session document you supplied stores the step identifier in more than one place. A shortened fragment looks like this:
flow: {
name: "BasicFlow",
currentStep: "TerminateSessionStep",
steps: [{ name: "WeatherStep", state: { /* ... */ } }],
memory: { "WeatherStep": { messages: [/* ... */] } },
sequence: [{ level: 1, stepName: "WeatherStep" }],
}
flow.currentStep answers “which registered step receives the next top-level
turn?” The flow.steps[].name value answers “which persisted state entry
belongs to this step?” flow.memory and flow.sequence carry related step
identifiers. In this completed capture the cursor is TerminateSessionStep,
but WeatherStep still appears in the stored state, memory, and sequence.
Those identifiers are normally derived from the step class’s id (which
defaults to its class name). Renaming WeatherStep to WeatherLookupStep
changes:
- the
namekey under which its state is stored and reloaded; - the value written to
flow.currentStep; - every entry in the execution
sequence; - its default memory namespace, if
useMemory(...)was not used; - any
flow.getStepState(OldStepClass, ...)call elsewhere; and - any application query that searches sessions by step name.
If a session's currentStep is "WeatherStep", the next restore cannot resolve it after the class is renamed. Even when the renamed step is not the current cursor—as in the completed capture above—its persisted steps[].name, memory namespace, sequence entries, and cross-step references still need migration if that stored history and state must be preserved. Handle all affected identifiers together in onRestoreSessionDoc(). See Session document migration.
The same applies to the flow’s registered name. Because the flow-name check happens before
the restore hook, a renamed flow cannot even reach your migration code — the request fails
with SESSION_FLOW_MISMATCH first.
The shared tool registry
Tool definitions are collected once per flow instance, at bootstrap, from two sources:
defineTool()on every registered step; anddefineTool()on the flow itself.
They are merged into one registry. Tool names must be unique across the entire flow. A duplicate throws at bootstrap:
Duplicate tool 'lookup_customer' registered in flow 'CustomerFlow'.
Define a tool once at flow level when several steps share it:
public defineTool(): ToolType[] {
return [{
name: "lookup_customer",
description: "Look up a customer by stable identifier",
schema: z.object({ customerId: z.string().uuid() }),
}];
}
Definition, exposure, and dispatch are three different things
| Concern | Mechanism |
|---|---|
| Definition | defineTool() — the name, description and Zod schema, registered flow-wide |
| Exposure | Which tools this step offers the model on this turn: @Tool-decorated methods plus anything named in useTool() |
| Dispatch | Which method runs when the model calls it: the @Tool-decorated method, or a same-named undecorated method |
@Tool covers exposure and dispatch together, which is why it is the preferred form. Use
@Tool("external_name") when the method name differs from the tool name. Decorated
handlers are inherited, so a subclass can reuse or override a parent’s handler.
A step is not offered tools it did not select, even though they exist in the flow registry. If the model hallucinates a tool that is not exposed, PicoFlow records a session warning and returns an informational tool message rather than failing the turn.
For a single model response containing several calls that should be processed together, add
a group handler with @Tools([...]). A matching group handler is authoritative — it shadows
the individual handlers completely, including for a single call, and must itself return a
valid route.