Lesson 3: Flow Shell & Engine Loop
Meet BasicFlow
src/myflow/basic-flow/basic-flow.ts defines the shell. The Flow constructor sets a flow-wide default model and wires a richer step list than the earlier tutorial examples:
export class BasicFlow extends Flow {
constructor() {
super(BasicFlow);
this.useModel('gpt-4o');
}
protected defineSteps(): Step[] {
const isPresident = this.getContext<boolean>('config.isPresident');
return [
new WeatherStep(this, !isPresident),
new NameStep(this).useMemory('default'),
new AddressStep(this).useMemory('default'),
new DOBStep(this).useMemory('default'),
new FooLogicStep(this).useMemory('default'),
new GooLogicStep(this).useMemory('default'),
new InContextStep(this).useMemory('separate').useModel('gpt-5.1', {
reasoning: { effort: 'low' },
}),
new ConcurStep1(this),
new ConcurStep2(this),
new ConcurStep3(this),
new ConcurStep4(this),
new PresidentStep(this, isPresident).useMemory('president'),
new FavoritesStep(this).useMemory('favorite'),
new EndStep(this).useMemory('temp'),
];
}
}
Tip
The order of steps is still deterministic — a single list, not an explicit graph. What changed is that the demo now uses that list to support branching, nested step execution, and a second fan-out mode that runs from the flow shell.
Session Document Lifecycle
Each HTTP turn:
- Loads the session document by
CHAT_SESSION_ID. - Hydrates memory for the active step only.
- Calls the LLM with that step’s prompt + tools.
- Executes any tool calls synchronously.
- Persists memory, step state,
sequence, tokens, and logs back to Mongo/Cosmos.
Because state is in Mongo, any container can handle the next turn.
Controller Wiring
Expose /ai/chat and pass control to the FlowEngine:
@Controller('ai')
export class TutorialController {
constructor(private flowEngine: FlowEngine) {
flowEngine.registerFlows({ BasicFlow });
flowEngine.registerModel(ChatOpenAI, { model: 'gpt-4o', apiKey: process.env.OPENAI_KEY });
}
@Post('chat')
async chat(
@Res() res: FastifyReply,
@Body('message') message: string,
@Body('flowName') flowName: string,
@Headers('CHAT_SESSION_ID') sessionId?: string,
@Body('config') config?: object,
) {
await this.flowEngine.run(res, flowName, message, sessionId, config);
}
}
Why This Matters
- Flow shell owns registry, model defaults, and deterministic step list.
- The engine’s recursive loop (LLM → tools → transitions → LLM) is hidden behind
run, so the controller stays tiny. - Memory is per-step, keeping prompts clean and avoiding cross-talk.
Next we’ll dive into the first step: multi-call capture with validation.