Lab 03 — CrewAI Flow Engine
Phase 19 · Lab 03 · Phase README · Warmup
The problem
A Crew is a self-contained task sequence. A Flow is CrewAI's event-driven orchestration layer — the thing you reach for when one crew is not enough: several crews (or plain steps) wired together with dynamic routing, branching, fan-in/fan-out, persisted state, and human-in-the-loop gates. You don't write the call graph by hand; you declare an event graph with decorators, and the engine fires methods as their triggers complete.
The interview probes here are precise:
- What does
@start/@listen/@routeractually do, and what does a@listenmethod receive? - How do
or_andand_gate a step (fire on ANY vs ALL)? - Structured vs unstructured state — the dict-with-a-UUID vs the typed model — and why you'd pick each.
- What does
@persistbuy you, and where does the state live?
You build the whole event engine, with the methods pure (they read/write self.state and
return values), so the flow is deterministic and offline.
What you build
| Piece | What it does | The lesson |
|---|---|---|
@start / @listen(trigger) / @router(trigger) | declare entry, chaining, and branching | the event graph is the control flow |
or_(...) / and_(...) + Condition | ANY-of / ALL-of trigger gates | fan-in and "either path" joins |
Flow.kickoff(inputs) | the round-based event loop | fires each method once → always terminates |
structured (initial_state dataclass) vs unstructured (dict) state | typed model vs free dict, both id-stamped | schema vs flexibility |
@persist + InMemoryFlowStore | save state per step | crash-safe resume (SQLite in real CrewAI) |
File map
| File | Role |
|---|---|
lab.py | your implementation (fill the # TODOs) |
solution.py | reference + worked example (python solution.py) |
test_lab.py | 21 tests: start, listen+payload, router branch, or_/and_, state, persist, termination, determinism |
requirements.txt | pytest only |
Run it
pytest test_lab.py -v
LAB_MODULE=solution pytest test_lab.py -v
python solution.py
Success criteria
-
You can explain what a
@listen(m)method receives (m's return value) and what a@routeremits (a label that fires@listen("label")). -
You can state the difference between
or_(ANY) andand_(ALL) gating and give a use for each (either-path join vs fan-in). - You can explain why the engine always terminates (each method fires at most once).
-
You can contrast structured vs unstructured state and name what
@persiststores and where (SQLite, keyed by the stateid). - You can say when you'd use a Flow over a Crew (multi-crew, branching, HITL) — and vice versa.
-
All 21 tests pass under both
labandsolution.
How this maps to the real stack
- This is CrewAI's
crewai.flow.flowmodule. RealFlow,@start(),@listen(...),@router(...),or_,and_, and@persistbehave as modeled:@listenfires on completion and receives the trigger's output;@routerreturns a string that routes to matching@listen;or_/and_gate on ANY/ALL.self.stateis a PydanticBaseModelfor structured flows (class MyFlow(Flow[MyState])) or a dict for unstructured — both get an autoid. - Real
@persistwrites to SQLite (SQLiteFlowPersistence) keyed by the stateid, so a flow can be resumed after a crash — the durable-execution idea from Phase 08, at the flow level. OurInMemoryFlowStoreis the same interface with a dict backend. - The real reason Flows exist: orchestrating multiple Crews with routing and
human-in-the-loop — a
@routercan branch on a human approval, and a@listencan run a wholeCrew().kickoff()and pass itsCrewOutputdownstream. Flow = the conductor; Crew = the section that plays.
Limits of the miniature. We use a stdlib @dataclass where real CrewAI uses Pydantic
(so no field validation/coercion), an in-memory store instead of SQLite, and an injected
counter id instead of a UUID (for determinism, per the LAB-STANDARD). We also fire each
listener at most once and process in deterministic rounds; real CrewAI's dispatch is
event-async, and an or_ listener can re-fire per trigger. The event-graph semantics — what
triggers what, what a listener receives, how branches gate — are faithful.
Extensions (your own machine)
- Put a real Crew inside a
@listen: have a step callSomeCrew().kickoff(inputs)and route on itsCrewOutput— the canonical "Flows orchestrate Crews" pattern. - Add human-in-the-loop: a
@routerthat returns"approve"/"reject"based on an injected approval callback, and resume the flow from persisted state after the human answers. - Swap
InMemoryFlowStorefor a SQLite store (stdlibsqlite3) keyed by the stateid, then kill the process mid-flow and resume — durable execution (Phase 08) for flows.
Interview / resume signal
"Built CrewAI's Flow engine from scratch —
@start/@listen/@routerevent decorators,or_/and_fan-in gates, structured vs unstructuredid-stamped state, and a@persiststore — as a deterministic round-based event loop, and can explain when a Flow (branching, multi-crew, HITL) beats a Crew and vice versa."