Guides
Create and register a flow
The shortest path from an empty file to a working endpoint: a Flow subclass, a step, provider adapters, engine registration, and the first HTTP call.
You need this when you are adding a new workflow to an application that already runs
PicoFlow, or when you are wiring PicoFlow into a service for the first time. A flow is not
usable until four things exist: the Flow subclass, at least one Step, a provider adapter
that can resolve the model you named, and a registration on the FlowEngine.
The shortest correct recipe
Four files, in this order.
1. The flow shell
// src/myflow/customer-flow/customer-flow.ts
import { Flow, Step, TerminateSessionStep } from "@picoflow/core";
import { CollectCustomerStep } from "./collect-customer-step.js";
export class CustomerFlow extends Flow {
protected configModel() {
return { provider: "openai", name: "gpt-4o-mini" } as const;
}
protected defineSteps(): Step[] {
return [
new CollectCustomerStep(this).useMemory("customer"),
new TerminateSessionStep(this).useMemory("end"),
];
}
}
configModel() is abstract — the build fails without it. defineSteps() returns every step
that can ever be activated in this flow, in order. The first entry becomes the initial cursor
for a new session.
The signature is protected constructor(flow: Flow); write new SomeStep(this). The initial step is the first entry of defineSteps(), or whatever initialStep() returns.
2. One step
// src/myflow/customer-flow/collect-customer-step.ts
import { Flow, Step, Tool, TerminateSessionStep, go, stay } from "@picoflow/core";
import type { ToolResponseType, ToolType } from "@picoflow/core";
import { z } from "zod";
export class CollectCustomerStep extends Step {
constructor(flow: Flow) {
super(flow);
}
public getPrompt(): string {
return "Ask for the customer ID, then call lookup_customer with it.";
}
public defineTool(): ToolType[] {
return [
{
name: "lookup_customer",
description: "Validate and save a customer identifier",
schema: z.object({ customerId: z.string().uuid() }),
},
];
}
@Tool
protected async lookup_customer(
args: Record<string, any>,
): Promise<ToolResponseType> {
const customer = await this.directory.find(args.customerId);
if (!customer) return stay("No customer matched that ID; ask for another.");
this.saveState({ customer });
return go(TerminateSessionStep).withPrompt("Confirm the saved customer.");
}
}
3. Register providers and flows on the engine
FlowEngine.create({ flows, providers }) is the form the demo application uses. It is a
static async factory that returns a configured engine.
// src/app.module.ts
FlowEngine.create({
flows: [BasicFlow, HotelFlow, InvoiceFlow, CustomerFlow],
providers: [
...ModelProvider.createBuiltinAdapters({
openai: { apiKey: config.get<string>("OPENAI_API_KEY") },
google: { apiKey: config.get<string>("GEMINI_API_KEY") },
anthropic: { apiKey: config.get<string>("ANTHROPIC_API_KEY") },
}),
],
});
PicoFlow ships no default model catalog and reads no API key on its own. If nothing registers
an adapter for the provider string in configModel(), the flow fails at bootstrap with
Model 'openai:gpt-4o-mini' is not registered and provider 'openai' has no adapter.
4. Call it
curl -i http://localhost:8000/ai/run \
-H 'content-type: application/json' \
-d '{
"flowName":"CustomerFlow",
"message":"Hi",
"config":{"tenantId":"demo"}
}'
The response carries a CHAT_SESSION_ID header and a matching session field. Send that
header back on every later turn of the same conversation.
How registration actually works
registerFlows() accepts either an array of constructors or a name-to-constructor map:
engine.registerFlows([CustomerFlow, HotelFlow]);
engine.registerFlows({
CustomerIntake: CustomerFlow,
});
The map form throws unless the key equals FlowClass.id, and Flow.id defaults to the class
name. So the map form is not a renaming mechanism by itself — override the static id on the
flow class when you want a public name that survives a TypeScript class rename:
export class CustomerFlow extends Flow {
static override get id(): string {
return "CustomerIntake";
}
// ...
}
The registered name is part of the persisted schema. A session document stores
flow.name, and the name check happens before onRestoreSessionDoc(), so a renamed flow
cannot migrate its own old sessions. See
One flow per session.
registerFlow(FlowClass) is a single-flow convenience wrapper. Prefer the bulk form during
bootstrap because it validates the whole set — duplicates, empty IDs, key mismatches —
before mutating the registry.
Decisions you are making here
| Decision | Options | Consequence |
|---|---|---|
| Registered name | Class name, or an overridden static id |
Baked into every session document; renaming breaks resume |
| Initial step | First entry of defineSteps(), or initialStep() |
initialStep() can read request context; the ordering rule cannot |
| Memory layout | Per-step default namespace, or shared via useMemory(...) |
Shared namespaces give continuity; separate ones isolate tool traces |
| Model scope | Flow default only, or per-step useModel(...) |
Step overrides are persisted in the session document |
| Terminal step | Include TerminateSessionStep, or call sessionCompleted() |
Conversations should terminate through the step; workers may not need it |
initialStep() is only worth overriding when the starting cursor depends on runtime context.
BasicFlow does exactly that:
protected initialStep() {
return this.getContext<boolean>("config.isPresident")
? PresidentStep
: WeatherStep;
}
Both classes stay registered in defineSteps(). Conditional activation is safe;
conditional registration is not, because a restored session may name a step that the
current defineSteps() no longer builds.
Failure modes
| Symptom | Cause |
|---|---|
FlowClass 'X' not registered. |
The class never reached registerFlows(), or the caller sent a different flowName |
Flow registration 'X' must match Flow ID 'Y'. |
Map-form key does not equal the class’s static id |
Flow 'X' is already registered. |
The same name was registered twice, often from two module imports |
Model '...' is not registered and provider '...' has no adapter. |
No adapter for the provider string in configModel() or useModel() |
Initial step 'X' is not defined in flow 'Y'. |
initialStep() returned a class that is absent from defineSteps() |
Duplicate tool 'x' registered in flow 'Y'. |
Two steps defined the same tool name; definitions are flow-wide |
Flow 'X' has not been bound to a FlowEngine. |
getFlowEngine() was called from init(), which runs before the engine is bound |
SESSION_FLOW_MISMATCH |
An existing session ID was reused with a different flowName |
init() runs after request context is added but before defineSteps() collects steps and before the engine binding exists. Use it only for setup that needs neither the engine nor a loaded session, and remember it runs on every request including restored ones.
Where to go next
Working through a flow line by line instead? Start at Bootstrapping PicoFlow in NestJS. For the normative API surface see FlowEngine and Flow.