BasicFlow tutorial
1. Bootstrapping PicoFlow in NestJS
PicoFlow has no server of its own. It is a library you register as one Nest provider, wire to a controller, and hand a session id round-trip.
Before any of the flow code makes sense you need to know what is holding it. PicoFlow
ships a FlowEngine class, not a framework runtime. You construct one, give it the
flow classes it is allowed to instantiate and the model providers it is allowed to
call, and then you call engine.run(...) from wherever your HTTP layer lives. In the
demo that layer is NestJS with a Fastify adapter, but nothing in the engine depends on
Nest.
What you will build
- One
FlowEngineregistered as a Nest provider, constructed from configuration. - Built-in model provider adapters for the providers you actually use.
- A custom adapter for a provider PicoFlow does not bundle.
- A
POST /ai/runcontroller that passes the session id both ways.
Registering the engine
From pico-demo/src/app.module.ts, lightly trimmed:
import { ModelProvider, FlowEngine } from "@picoflow/core";
import { BasicFlow } from "./myflow/basic-flow/basic-flow.js";
import { HotelFlow } from "./myflow/hotel-flow/hotel-flow.js";
import { InvoiceFlow } from "./myflow/invoice-flow/invoice-flow.js";
@Module({
imports: [ConfigModule.forRoot()],
controllers: [TutorialController, AiController, HealthController],
providers: [
{
provide: FlowEngine,
useFactory: (config: ConfigService) =>
FlowEngine.create({
flows: [BasicFlow, HotelFlow, InvoiceFlow],
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") },
}),
ModelProvider.createCustomAdapter({
provider: "nvidia",
runtimeProvider: "openai",
config: {
apiKey: config.get<string>("NVIDIA_API_KEY"),
configuration: {
baseURL: "https://integrate.api.nvidia.com/v1",
},
},
}),
],
}),
inject: [ConfigService],
},
],
})
export class AppModule {}
Four things are happening.
flows is a registration list of constructors, not instances. FlowEngine stores the
classes and builds a fresh Flow object per request through FlowCreator.create(...).
That is why a Flow subclass must have a no-argument constructor, and why flow
instances are never shared between sessions.
FlowEngine.create(...) is declared async and returns a Promise<FlowEngine>. Nest
awaits a promise returned from useFactory, so this works without an explicit
await. If you construct the engine outside Nest, remember to await it.
ModelProvider.createBuiltinAdapters(...) returns an array of adapters for every
bundled provider — openai, azure-openai, google, anthropic, deepseek, moonshot, zai,
ollama, openrouter. Passing configuration only for the ones you use is fine; the rest
are still registered but will fail if a flow ever selects them. The demo comments out
the unused entries rather than deleting them, which is a reasonable habit.
ModelProvider.createCustomAdapter(...) is the escape hatch. NVIDIA exposes an
OpenAI-compatible endpoint, so runtimeProvider: "openai" reuses PicoFlow’s bundled
OpenAI runtime while provider: "nvidia" gives it a distinct name that a step can
select with .useModel({ provider: "nvidia", ... }). The integration stays owned by
the application; PicoFlow does not have to ship an NVIDIA adapter for you to use one.
The OpenAI adapter declares a capability: temperature is unsupported for models matching gpt-5 and the o-series. A temperature override on those models is dropped with a session warning rather than failing the request.
The controller
From pico-demo/src/controllers/ai-controller.ts, trimmed of Swagger decorators:
@Controller("ai")
export class AiController {
constructor(@Inject(FlowEngine) private flowEngine: FlowEngine) {}
@HttpCode(HttpStatus.OK)
@Post("run")
async run(
@Res() res: FastifyReply,
@Body(K.message) userMessage: string,
@Body(K.flowName) flowName: string,
@Body("config") config: object,
@Headers(K.ChatSessionID) sessionId?: string,
) {
const result = await this.flowEngine.run({
flowName,
userMessage,
sessionId,
config,
});
if (result.session) {
res.header(K.ChatSessionID, result.session);
}
if (!result.success) {
res.status(HttpStatus.BAD_REQUEST);
}
if (result.contentType && result.contentType !== HttpContentType.Plain) {
return res.type(result.contentType).send(result.message);
}
return res.send(result);
}
}
K is the framework’s constant table, so the wire format is not restated as string
literals in your code. K.message is "message", K.flowName is "flowName", and
K.ChatSessionID is "CHAT_SESSION_ID".
flowEngine.run(...) takes { flowName, userMessage, sessionId, config } and returns
{ success, completed, message, session, contentType }. Note that success: false is
returned as a value, not thrown: the engine catches flow errors, marks the session
document aborted, persists it, and returns the message. The controller turns that
into a 400.
The CHAT_SESSION_ID round-trip
There is exactly one piece of client state, and it is the session id.
turn 1 -> POST /ai/run (no CHAT_SESSION_ID header)
<- 200, header CHAT_SESSION_ID: 9f3c... body.session: 9f3c...
turn 2 -> POST /ai/run header CHAT_SESSION_ID: 9f3c...
<- 200, same id
When sessionId is absent the engine creates a new session document, generates a
UUID, and returns it in both the response header and the body. The client echoes it
back on every subsequent turn. Everything else — the current step, the conversation
memory, every step’s state, the token tally — lives in the persisted document, keyed
by that id.
main.ts has to make that header usable from a browser:
app.enableCors({
origin: "*",
methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS",
allowedHeaders: "Content-Type, Accept, Authorization, CHAT_SESSION_ID",
exposedHeaders: "CHAT_SESSION_ID",
});
exposedHeaders is the one people forget. Without it the browser receives the header
but JavaScript cannot read it, and every turn starts a new session.
How it works
The request path is short and worth memorising:
AiController.run
-> FlowEngine.run
-> flowSession.withSessionLock(sessionId)
-> FlowCreator.create(flowName, sessionId, engine, { config })
-> new BasicFlow()
-> flow.init()
-> flow.collectSteps() // calls defineSteps()
-> flow.bootstrap(...) // fetch or create the session doc
-> flow.run(userMessage)
-> flow.saveSession()
The session lock is taken before the flow is constructed and released after the document is written, so two concurrent requests for the same session id serialize rather than racing. The session store itself also uses a revision-based compare-and-swap.
config is passed into flow context on creation only. FlowEngine.run wraps it
as { config: input.config } and FlowCreator calls flow.addContext(context)
before bootstrap. When an existing session is restored, readDoc() overwrites the
in-memory context with the persisted one. A new config object on turn 5 does not
reconfigure a session that started on turn 1.
Why it is written this way
The engine is a provider rather than a Nest module because it has no Nest
dependencies at all. It reads configuration through a ConfigManager abstraction and
resolves models through adapters you supply. That keeps the demo honest: if you drop
Fastify for Express, or Nest for a plain HTTP handler, only ai-controller.ts changes.
Registering providers explicitly, rather than reading environment variables inside the framework, means model access is auditable in one file. You can see at a glance which providers this deployment can reach.
Common mistakes
- Constructing a
FlowEngineper request. It owns the session store and the model registry. Register it once as a singleton provider. - Forgetting
exposedHeaders: "CHAT_SESSION_ID". The server behaves correctly, the tests pass, and every browser turn silently starts a fresh session. - Expecting
configto apply on later turns. It initialises flow context for a new session only. Domain values belong in the state of the step that collects them. - Assuming a failed flow throws.
FlowEngine.runreturns{ success: false, message }for most errors. If your controller only checks for exceptions it will return 200 on a broken session.
Next
With the engine wired up, 2. Your first flow
writes the Flow subclass it is allowed to instantiate.