picoflow.io

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 so you don’t repeat it on every step:

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', {
        reasoning: { effort: 'high' },
      }),
      new PresidentStep(this, isPresident).useMemory('president'),
      new FavoritesStep(this).useMemory('favorite'),
      new EndStep(this).useMemory('temp'),
    ];
  }
}
Tip

The order of steps is deterministic — a single list, not an explicit graph. Transitions are driven by tool results and logic steps.


Session Document Lifecycle

Each HTTP turn:

  1. Loads the session document by CHAT_SESSION_ID.
  2. Hydrates memory for the active step only.
  3. Calls the LLM with that step’s prompt + tools.
  4. Executes any tool calls synchronously.
  5. 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.