The bot has two brains. One decides what to do inside a single message (the Graph). The other remembers a task that spans many messages — like booking an appointment (the FSM / Flow). This page shows how they meet at one guard called FlowGate, and how their memory lives in one place called FlowState. Real function names, mock chat data, lots of pictures.
Think of a hotel concierge. The Graph answers whatever you ask right now. The Flow is the concierge remembering "we're in the middle of booking your dinner reservation." FlowGate is the rule: before answering anything new, check if we're mid-task and continue that first.
Decides which steps run and in what order: search inventory? answer a question? show cars? It's the playbook a dealer (now: our admin) draws on a canvas.
Real code: GraphStepCompiler::walk() LIVE, node types goal · ask · retrieve · tool · decide · say · handoff
A small state machine: BookingFlow walks idle → awaiting_time → done. It owns the rules: how many reschedules, confirm cancel once, attach the car.
Real code: BookViewingTool / CancelViewingTool LIVE → become BookingFlow / CancelFlow NEW
Its only job: "Is a task already open? → continue it. If not → let the Graph route normally." That single rule is what makes the bot predictable: finish what you started before starting something new.
state.flow).Today the bot scribbles task-memory in 4 different scattered places (booking_ask, booking_decision, cancel_ask, playbook). FlowStateStore replaces them with one tidy object so we can always answer "what's the bot doing and why."
state.flowIt's one small chunk of JSON saved on the conversation row (conversations.state, a jsonb column LIVE). At any moment, this fully answers "what task is open, where are we, how many times have we asked."
// conversations.state.flow — the ONE open-task slot (NEW, docs/33) { "schema": 1, // version of this shape "kind": "booking", // booking | cancel | graph_ask "status": "awaiting_time", // where in the lifecycle we are "slots": { // data gathered so far "vehicle_id": 8842, "type": "viewing", "date": null // still missing → that's why we're asking }, "asks": { "awaiting_time": 1 }, // re-ask counter (budget guard) "priority": 50, // who wins if two tasks collide (fixed per kind) "engine_scope": "agnostic", // "agnostic" survives a crash; "graph" does not (see §6) "opened_turn": 14, "ttl_turns": 3 // auto-forget if ignored this many turns }
Every customer message runs through a list of small steps (TurnPipeline::run() LIVE). FlowGate is just a new step — placed after "understand the message" but before "route / pick what to do." Both brains (the Orchestrator and the Graph engine) run the same pipeline, so FlowGate works the same in both.
blue = LLM reads the message ·
amber = the new guard ·
green = an action can happen here.
Today the same trick already exists in a small way: TurnSteps::hasPendingBookingFlow() LIVE. FlowGate just generalizes it to every task.
// FlowGate — runs every turn, BEFORE routing (NEW, docs/33) open = FlowStateStore::read(conversation) // look in the notebook if open is null: return CONTINUE // no task → let the Graph route normally decision = registry.flow(open.kind).transition(open, understanding, world) match decision.intent: HANDLE → run effects, save next_state, reply // continue the task YIELD → park the task, let Graph answer ONE thing // e.g. a side question PREEMPT → higher-priority task takes over // rare, by fixed priority ABANDON → close the task, Graph routes fresh // "never mind"
Watch the notebook (state.flow) change. Customer is chatting on LINE about a BYD Atto 3 (vehicle #8842).
FlowStateStore::write(...) saves a brand-new task:
state.flow = { "kind": "booking", "status": "awaiting_time", "slots": { "vehicle_id": 8842, "type": "viewing", "date": null }, "asks": { "awaiting_time": 1 }, "engine_scope": "agnostic" }
The task isn't lost — it's parked. Notebook just flips status:
state.flow.status = "parked" // still there. No second task is allowed to open while parked (no-stack rule, D33-15)
Important rule: the flow doesn't write to the DB itself. It requests effects; a tiny executor runs the real services. (Keeps writes in one trusted place.)
// BookingFlow.transition() returns a PURE decision — no DB writes inside (NEW, D33-11) FlowDecision { next_state: "done", effects: [ { do: "book_appointment", vehicle_id: 8842, type: "viewing", at: "2026-06-23 14:00" }, { do: "attach_interest", vehicle_id: 8842 } ], terminal: true } // the executor performs them, THEN clears the notebook (LIVE services): AppointmentService::book(lead, "viewing", "2026-06-23 14:00", vehicle_id:8842) LeadVehicleInterestService::attach(lead, 8842, source:"chat_matched") FlowStateStore::clear(conversation) // task done → notebook empty again
attach_interest effect always rides along with a booking — it no longer depends on the lead being "qualified" first.The conflict check is now keyed on lead + type + pending + future — so a different type never triggers the awkward "you already have an appointment" prompt. NEW
A real counter on the row decides the cap. On the 4th, the flow asks for a handoff — and the new consumer actually makes it happen:
// 4th reschedule → flow returns: FlowDecision { effects: [{ do: "force_handoff", reason: "reschedule_cap" }], terminal: true } // D33-10 (NEW): TurnRunner now CONSUMES force_handoff (today it ignores it!) conversation.handoff_state = "pending" conversation.handoff_reason = "reschedule_cap" // reason carried, not hardcoded notifyStaff(); suppressNormalReply();
| Job | Who does it | Status |
|---|---|---|
| Read the message, pull out intent & entities | stepUnderstand (the LLM) | LIVE |
| Decide which steps run this turn | GraphStepCompiler::walk() | LIVE |
| "Are we mid-task? continue it" — the guard | FlowGate | NEW |
| Decide the next step of a task (pure, no writes) | BookingFlow.transition() → FlowDecision | NEW |
| Read/write the open-task memory (the ONLY writer) | FlowStateStore | NEW |
| Actually write to the database | AppointmentService, LeadVehicleInterestService | LIVE |
| Turn facts into a nice reply (phrasing only) | stepGenerate (the LLM) | LIVE |
| Catch a lie ("I booked it" when nothing was booked) | stepBookingGuard | LIVE |
The Graph engine is the normal driver. If it ever crashes mid-conversation, the old reliable Orchestrator takes over for that turn (TurnDispatcher::runLiveWithFallback() LIVE). Here's the danger — and the fix.
On a crash, the code wipes the task memory wholesale:
// TurnDispatcher.php:96 (LIVE) PlaybookCursor::clear(conversation) // erases everything
Fine today (booking lives in a separate key). But once booking moves into the shared state.flow, a graph hiccup would erase a customer's in-progress appointment. 😱
Cleanup becomes picky about what it clears:
// FlowStateStore (NEW, D33-14) suspendGraphAskOnFallback(): if flow.kind == "graph_ask": clear // graph-only, ok to drop if flow.engine_scope == "agnostic": KEEP // booking/cancel survive!
That's the meaning of engine_scope: agnostic tasks (booking, cancel) survive a crash; graph tasks (a canvas question) don't need to.
Because the Graph is a per-turn thing — it forgets between messages. The booking rules (how many reschedules, confirm cancel once, attach the car) need to live across messages and must work even when the Graph isn't driving. So:
book_viewing node in the flow| Term | Plain meaning | Real name |
|---|---|---|
| Graph | the per-message router/playbook | GraphStepCompiler LIVE |
| Flow / FSM | a multi-message task with rules & states | BookingFlow / CancelFlow NEW |
| FlowGate | "finish the open task before starting new" | FlowGate (a pipeline step) NEW |
| FlowState | the saved "what task is open" memory | conversations.state.flow NEW |
| FlowStateStore | the only code that reads/writes that memory | FlowStateStore NEW |
| transition() | "given where we are + this message, what next?" (no writes) | Flow.transition() → FlowDecision NEW |
| effect | a requested write; a service actually does it | AppointmentService::book() LIVE |
| engine_scope | "agnostic" tasks survive a crash; "graph" tasks don't | field on state.flow NEW |