# WhatsApp · Flows (Manage)

**Portal:** Manage · **Status:** **Phases 1–3 shipped** (reactive keyword · objective auto-detection · proactive Google Sheet trigger) **+ Campaign trigger & per-run variables (2026-07-02) + steps versioning & Runs view (2026-07-12) + interactive WAIT steps / menu trees (2026-07-13) + flow TYPES & parallel run slots (2026-07-14) + proactive TEMPLATE MENUS, no-answer reminders & "Send to customers" (2026-08-04)** · **Nav:** "WhatsApp Automation → Flows" · **Routes:** `manage.messages.flows.*` (+ `flows.runs`, `flows.dispatch`, `conversations.stop-flow`, public `webhooks.whatsapp.sheet`)

## What it does
A **flow** is a conversation automation on a channel, one of **two product types** (`flow_type`, picked at creation and fixed after — the whole builder is tailored to it):

- **Time-based drip (`TYPE_TIME_BASED`)** — a background timed sequence: "send this after X, that after Y of the last message" (day-0 / day-3 / day-7 nurture). It **never waits for a reply**. Triggers: keyword, Google Sheet, Campaign (API).
- **Rule-based bot (`TYPE_RULE_BASED`)** — an interactive menu tree: keyword → message **with tappable options** (a PREMO-style menu bot: "1. 中文 / 2. English") → the pick decides the next message (or End / AI / Handoff). Every send is **immediate** (delays forced to 0); an unmatched reply runs an **admin-configurable fallback** (re-ask × N, then end / AI / handoff). Triggers: keyword (reactive), or — since 2026-08-04 — **Campaign / Send-to-list** (a PROACTIVE bot whose opener is a **template MENU**, see below).

After a flow's tree finishes, an optional **AI profile** takes over toward an **objective**. Under the hood both types share ONE engine and step model (`advance_mode` AUTO/REPLY) — the type is a product-level label that tailors the builder, the validation and the **parallel-slot rules** below.

> **PARALLEL RUN SLOTS (2026-07-14).** A conversation holds **one run slot per type**: a RULE_BASED bot and a TIME_BASED drip can run **at the same time** on the same customer. The **bot OWNS the interactive conversation** — menu replies route to it, and in AI resolution the bot's profile outranks the drip's (`orderByRaw flow_type=RULE_BASED desc` in `WhatsappAiConfig::activeFlowConfig` + `completeObjective`, so `[[OBJECTIVE_MET]]` ends the OWNING run and the drip's AI takes over only after the bot ends — pure precedence, no suspend state). The **drip just fires its timed sends in the background** (per the user's decision, even mid-menu). A drip whose steps finish while the bot is active enters its AI phase **silently** (`AdvanceFlowRun::engageAi` bot-guard), and a **stale queued `GenerateAiReply`** (dispatched for the drip before the bot started) is skipped at run time (`engagementSkipReason` → `bot_active`). A **handoff — keyword OR menu option/fallback — ends EVERY run** + hands the thread off (a background drip must never keep messaging a human-served customer); `AdvanceFlowRun` also refuses to send into a handed-off thread (send-time backstop ends the run `handoff`), and `StartProactiveFlow` never opens a run on one. A menu option targeting **AI on a profileless bot ends the run** (`drip_done`) instead of zombie-ing in an AI phase that would hold the slot and outrank the drip's AI. Slot guards are per-type at every start path: keyword (`matchingFlow` skips occupied types), CTA link, `StartProactiveFlow` — so a keyword can start the bot mid-drip, but never a second run of the same type; **all three starters serialise under the same `wa:flow-start:{conversation}` lock** (the proactive job releases itself +10s on lock timeout). `runs.flow_type` is denormalised at `startRun` so the hot checks never join, and the minutely reaper **re-syncs any RUNNING run whose type disagrees with its flow** (heals the stale-worker deploy window). The per-flow Runs page's Stop scopes to its own run (`stop-flow` + `run` id); the inbox's per-run Stop does the same.

> **PROACTIVE TEMPLATE MENUS + NO-ANSWER REMINDERS + "SEND TO CUSTOMERS" (2026-08-04).** The property-match Zoom-invite use case: blast a personalised template ("Hi {{name}}, your property match result: {{result}} — which time suits a 1-1 Zoom?") to a pasted list of numbers, let the customer answer by **tapping a button**, re-send if they stay silent, and **alert the team** when they never answer.
> - **A TEMPLATE step can now WAIT (rule-based bots).** Its menu is the template's own **QUICK_REPLY buttons** (authored on the template, Broadcasts → Templates — up to 10; WhatsApp shows ≤3 as buttons, more behind a "See all options" expander). The builder auto-derives one option per button (label locked to the button text; the admin only wires each answer's target — another step's `step_key` / end / ai / handoff) and stores `button_index` on each option. At send time `AdvanceFlowRun::quickReplyPayloadButtons` emits one `quick_reply` **payload component per option (payload = the option id)**, so a tap comes back as a Cloud `type: button` webhook whose payload IS the interactive reply id (`CloudApiDriver::buttonContext` → `meta.context.interactive.id`) and `matchMenuOption` routes it **exactly**; a customer who TYPES "1" / the label instead still matches by the existing number/label rules. Templates are **window-exempt**, which is what makes this the one legal proactive menu on Cloud — a native interactive list (the in-window WAIT step) cannot open a conversation. `UpdateRequest` cross-checks every option's `button_index` against the registry row's QUICK_REPLY buttons.
> - **No-answer reminder ladder (any WAIT step).** New keys on the step's `fallback` json — `remind_after_minutes` (silence before each re-send), `max_reminders` (0–3, default 2), `notify_on_silence` (default true) — parsed by the shared `WhatsappFlowStep::reminderConfig()`. **Chain pacing = delivery ORDER (2026-08-15):** consecutive "immediate" sends are paced a beat apart — the next tick dispatches ≥2s after a just-sent TEXT and ≥4s after a just-sent MEDIA (a skipped step paces nothing; a real drip delay ≥ the beat is untouched). The sends run on PARALLEL workers, and a fast text queued right behind a slow image upload overtakes it — verified live on flow 2388: the 183KB media opener arrived AFTER the text that followed it, so the customer read message 2 above message 1. Same principle as the funnel composer's ≥2s bubble gap. The builder copy now says "Messages send in order, a couple of seconds apart" instead of "immediately". **The "ANY reply" wait (2026-08-15):** a TEXT step may wait with **zero options**. It parks exactly like a menu — the no-answer reminder ladder (per-rung waits + texts) runs unchanged — but the FIRST reply of **any kind** (text, image, a payment screenshot, a sticker) is the answer: deliberately type-blind, because the typical ask is "send me the payment screenshot" and a menu match would only re-ask past the very image the step requested. The reply routes via **`fallback.on_reply`** (a step key or end / ai / handoff, same contract as an option's target, cross-validated in `UpdateRequest`; `'ai'` returns false from `considerFlow` so the flow-aware AI answers that very message — a profileless bot still ends instead, per the 2026-07-14 zombie-slot guard). The park predicate is `AdvanceFlowRun::stepWaits()`: `ADVANCE_REPLY && (options || TYPE_TEXT)` — a TEMPLATE wait still requires its quick-reply buttons (they ARE its menu), and no pre-feature snapshot carries `ADVANCE_REPLY` without options, so nothing old changes meaning. The builder shows an "on any reply →" select in place of the options list, hides the unmatched-reply fallback row (nothing can mismatch), and the outbound is a plain bubble (no `meta.interactive`); the graph shows it without a fan-out, which is accurate. One legacy-edge change rode along: a parked run whose LIVE step lost its options mid-park (pre-snapshot runs only) used to clear-and-resume — it now routes `on_reply` (default end). **Each rung waits its OWN time too (`remind_schedule`, 2026-08-15):** a minutes array index-aligned with the messages — rung 1 fires after `schedule[0]` of silence, rung 2 after `schedule[1]` MORE (each gap counts from the previous event: the park, the previous reminder, or the customer's last activity), so "30 minutes then a gentle nudge, 4 hours then a firmer one" is one ladder. `remind_after_minutes` stays as the ladder's ON switch and the fallback for any blank rung — `reminderConfig()` pads the schedule with it, which is exactly how every pre-schedule flow (and every run snapshot frozen before this shipped) keeps its old cadence. The give-up past the LAST rung reuses that rung's own gap, never the legacy shared value. Applies to template menus too (the schedule is engine-level; only the TEXTS are text-menu-only). **Each rung can say its OWN thing (`remind_messages`, 2026-08-15):** an array index-aligned to the ladder — entry 0 is the first reminder's text, entry 1 the second — so the first nudge can be gentle and the second firmer, instead of the identical message twice. A blank rung re-sends the step's own message (the original behaviour, rung by rung; positions are preserved as authored, so `['', 'firmer']` never shifts). Rendered with the same `{{variables}}` as the step body and **re-carrying the options menu**, so the customer can still tap. **TEXT menus only** — a template menu's content lives at Meta (the template IS the message, and its window-exempt re-send is the whole point), so the engine ignores authored texts there and the builder doesn't offer them. The builder shows one input per rung under the ladder controls; adding the key changes the steps-hash contract, so every WAIT-step flow re-versions once on its next save (harmless, expected). Locked by the per-rung tests in [FlowTemplateMenuTest](/tests/Feature/Whatsapp/FlowTemplateMenuTest.php). The minutely reaper's `remindSilentMenus()` pass owns the clock: the **silence anchor is the LATEST of park time (`meta.awaiting.parked_at`, stamped by `setAwaitingReply`) / last reminder / last customer activity** (an unmatched reply burns a fallback attempt but resets the silence clock — the customer wasn't silent). A due reminder calls `bumpMenuReminder` (reminders++ + **hop++**, so the per-(run, position, hop) send idempotency lets the SAME step re-send; `next_step_at = now` makes a lost reminder tick self-heal via the stalled-drip pass) and re-dispatches `AdvanceFlowRun`, which re-sends and re-parks with the counters preserved. Once the ladder is exhausted — or a re-send is impossible (a **free-form** menu with the Cloud window closed; a **template** menu can always re-send) — the reaper fires the **[Notify](/docs/modules_handbook/shared/notify/readMe.md)** event **`whatsapp.flow_no_response`** (contact, flow, reminders sent, deep link to the thread; throttled per run) and ends the run **`no_response`** (new `ENDED_NO_RESPONSE`). `endIdleRuns` **defers to an active ladder** (a 1-minute idle timeout must not kill a menu waiting on its 4-hour reminder); the 7-day hard cap stays the final backstop.
> - **"Send to customers" (the admin-facing bulk dispatch).** The flow edit page of any **proactive** flow (Campaign / Sheet) gains a header button → `FlowSendModal`: paste **one number per line** or a **CSV with a header row** (`phone,name,result,…` — quote-aware client-side parse; a phone-ish header normalises to `phone`), preview (valid/invalid counts, detected `{{variable}}` chips, first rows, the paced-blast time estimate from `dispatchSpacing`), then `POST flows/{id}/dispatch` (`DispatchRequest`, ≤500 rows/request — the sheet cap; `FlowsController::dispatchRecipients` re-runs the activation guard first). Every row goes through the existing **`FlowDispatcher`** → one paced `StartProactiveFlow` per recipient on the broadcast lane — so the endpoint **cannot burst**, opted-out (STOP) / blocked numbers are skipped per recipient, extra CSV columns ride as the per-run `{{variables}}` bag, and the per-type slot guard means nobody is double-entered. The Campaign trigger tab is therefore no longer drip-only — a bot with a template-menu opener is exactly what it dispatches (the "proactive Cloud flow must open with a template" guard applies unchanged).
> - Locked by [FlowTemplateMenuTest](/tests/Feature/Whatsapp/FlowTemplateMenuTest.php) (payload buttons + park, real Cloud button-tap webhook routing, typed-label routing, the reminder ladder end-to-end incl. the Notify fan-out and the idle-kill deference, dispatch endpoint, builder round-trip).

> **Cross-flow jumps were REMOVED (2026-07-13).** The old Phase-2/2.5 concepts — `branches` (deterministic keyword jump to another flow), `routing_hint` + AI `[[GOTO]]` intent routing, and `next_flow_id` objective chaining — are all gone (columns dropped, engine paths deleted). A flow is now a **self-contained tree**; in-flow menu options replace what branches did, and are deterministic + provider-native. `[[OBJECTIVE_MET]]` auto-completion remains, but simply **ends** the run.

> **Reactive = safe on BOTH providers.** Because the customer messaged first, the 24h window is open and free-form sends are legal on Cloud *and* Bridge — so a flow can use Baileys freely. The **Cloud-only restriction is a broadcast-only rule** (cold bulk messaging), and does **not** apply here. The only per-provider rule that bites a flow is the Cloud 24h window: a free-form drip step landing after it closed is **SKIPPED** (recorded on the run + shown amber in the monitor) and the walk **continues** — a later TEMPLATE step is window-exempt and still goes out, and the window may reopen (any customer reply) before a later free-form step. (The old behavior — ending the whole run `window_closed`, taking later template steps down with it — was retired 2026-07-12.) The Bridge has no window and is never gated.

> **Channel-aware template rules (2026-07-12 hardening).** TEMPLATE steps only exist on **Cloud** (the Bridge has no Meta templates — `BridgeDriver::sendTemplate` now **throws** instead of leaking the raw template name to the customer as text; the builder hides "Add template" on Bridge flows). A template step is validated against the **approved-template registry** at FOUR points with one shared `TemplateStepValidator`: **save** (`Flows/UpdateRequest::withValidator` — exists + APPROVED + non-Authentication + param counts), **activate** (`FlowsController::channelGuardError`, which also hard-blocks a **proactive Cloud flow without a template opener** — a fresh contact has no open window, so a free-form opener would kill the whole dispatch silently; `StartProactiveFlow` re-checks at dispatch time for legacy/API paths), **send** (`AdvanceFlowRun::templateInput` re-checks APPROVED — a template PAUSED/REJECTED since activation ends the run `template_unavailable` instead of hammering Meta and hurting the number's quality rating), and **sandbox** (`SandboxDriver::sendTemplate` uses the same validator when the channel has registry rows, so a rehearsal catches what the real channel would). The builder's template step is a **picker** over the channel's APPROVED non-Auth templates (prop `templates` from `FlowsController@edit`) with param inputs auto-derived from the components (distinct `{{n}}` via `utils/templateComponents.js`), header-text / URL-button variable slots, and a `TemplatePreview` bubble. Body params are **positional**: an empty rendered value keeps its slot with a `-` placeholder (dropping it shifted every later param — Meta error 132000). The persisted message `body` is a **rendered preview** of the template text (via `WhatsappTemplate::renderBody`), so the thread bubble and conversation-list preview show what the customer received, not the internal template name. Run `php artisan whatsapp:flows-audit` after deploying to list legacy flows the new guards would reject.

> **Consent is NOT gated in flows** (per product decision — everything is treated as consented for now; real consent logic lands later). The broadcast module keeps its own deliberate consent gate; flows do not touch it.

## How it works
- **Data model (3 tables).** `WhatsappFlow` (key model: `channel_id`, `status` INACTIVE/ACTIVE, **`flow_type`** (`TYPE_TIME_BASED`/`TYPE_RULE_BASED` + `FLOW_TYPES`, `isTimeBased()`/`isRuleBased()` — fixed after creation), `trigger_type`/`trigger_config` json, `priority`, `ai_profile_id` nullable, `objective`, `sheet_token` (Google Sheet trigger), `idle_timeout_minutes`, **`steps_version` + `steps_hash`** (content-hash versioning, see *Steps versioning*); `trigger_type` is `TRIGGER_KEYWORD`, `TRIGGER_SHEET` or **`TRIGGER_CAMPAIGN`** (API-started — see *Campaign trigger* below); `PROACTIVE_TRIGGERS` = SHEET + CAMPAIGN, `isProactiveTrigger()`). `WhatsappFlowStep` (child, no uuid; ordered `position` + **`step_key`** (the step's STABLE identity — option targets point at it; positions renumber on every save, keys don't; minted by the builder or the repository) + `delay_seconds` (validated ≤ **30 days** = 2592000; the builder edits value + unit seconds/minutes/hours/days) + `type` **text / media / template** (`TYPE_TEMPLATE` = a Cloud proactive opener) + `body` + `media_id` + **`advance_mode`** (`ADVANCE_AUTO` timed / `ADVANCE_REPLY` wait-for-reply; `waitsForReply()`) + **`options` json** (`[{id:'oN', label, next: stepKey|'end'|'ai'|'handoff'}]`) + **`fallback` json** (`{message, max_retries 0–5 (default 2), after: end|ai|handoff}`) — steps are replaced as a group on save). `WhatsappFlowRun` (child state machine, **one per conversation PER TYPE** — `flow_type` denormalised from the flow at start: `status` RUNNING/ENDED, `current_step`, `next_step_at`, `drip_completed_at`, `last_customer_at`, `ended_reason`, `steps_snapshot` + `steps_version`/`steps_hash` (frozen at start), **`variables` json** — the per-run personalisation bag, see below; `meta.awaiting` `{step, attempts}` while parked at a menu, `meta.hop` — the jump counter that keys re-send idempotency).
- **Cardinality.** 1 channel has many flows; 1 flow has ≤1 AI profile; a conversation holds **one run slot per flow TYPE** (≤1 bot + ≤1 drip in parallel). While any run is active the flow layer **supersedes the channel's default AI profile** — the bot's profile first, then the drip's, then the channel default.
- **Trigger (Phase 1 = keyword).** On every inbound, `ProcessInboundWhatsAppWebhook::considerFlow()` runs before the default AI. With no active run, it finds the **highest-`priority` ACTIVE keyword flow** on the channel whose `trigger_config` matches the inbound TEXT (`WhatsappFlow::matchesText()` — **exact** or **contains**, case-insensitive by default), starts a `WhatsappFlowRun`, and dispatches `AdvanceFlowRun`. The trigger message is answered by the drip, not the default AI. The start is serialised under a per-conversation **`Cache::lock('wa:flow-start:{id}')`** (shared with the [CTA link](/docs/modules_handbook/manage/messages/whatsapp/cta_link.md) starter, added 2026-07-02) — two rapid keyword messages landing on two Horizon workers previously both saw "no active run" and could start **two** RUNNING runs (double drip); now the loser of the lock re-checks and treats the inbound as a normal mid-run message.
- **Scripted drip (`AdvanceFlowRun`).** A queued pacer sends one step at a time: render the body's variables (`{{first_name}}` / `{{name}}` / `{{phone}}` **plus every key in the run's `variables` bag** — a run variable with the same key as a built-in wins), `createOutbound` (text, media, or a validated template step) → `NewWhatsAppMessage` → `SendWhatsAppMessage` (the normal outbound pipeline — echo, ticks, failure visibility), then re-dispatch itself after the next step's `delay_seconds` — **unless the step waits for a reply** (see next bullet), in which case the run is parked instead. **Idempotent per (run, step, hop)** (a re-send check on `meta.flow`; the `hop` counter lets a revisited menu step re-send) + single-flighted per run (`WithoutOverlapping`), with a `failed()` net + the reaper backstop. **Customer replies during a timed drip do NOT interrupt it** — the scripted sequence always completes first.
- **Interactive WAIT steps (2026-07-13).** A TEXT step with `advance_mode = ADVANCE_REPLY` + at least one option is a **menu**: after sending, `AdvanceFlowRun` calls `setAwaitingReply` — the run parks (`next_step_at` null, `meta.awaiting = {step, attempts}`) and waits for the customer. The outbound carries `meta.interactive.options`, and `SendWhatsAppMessage` routes it to the driver's **`sendInteractive`**: **Cloud** renders NATIVE tappable UI (≤3 options → reply **buttons**, titles truncated at 20 chars; 4–10 → a **list** menu, row titles at 24), **Bridge** renders a numbered text menu (`1. 中文` …), **Sandbox** accepts. On the next inbound, `ProcessInboundWhatsAppWebhook::handleAwaitingReply` matches the reply in priority order: **native interactive reply id** (Cloud button/list tap, already parsed to `meta.context.interactive`) → **bare number** ("2" = second option) → **exact label** (case-insensitive). A match routes via `applyMenuTarget`: another step's `step_key` → **`jumpRun`** (cursor + `meta.hop++` + schedule `AdvanceFlowRun` after the target's delay — hops may go BACKWARD, e.g. "back to main menu", and the hop counter defeats the per-position idempotency so the menu re-sends), `'end'` → `endRun(drip_done)`, `'ai'` → `completeDrip` (AI phase; THIS message is answered flow-aware), `'handoff'` → `endRun(handoff)` + conversation handed off. **No match** → the step's **fallback**: bump `attempts`, and while `attempts ≤ max_retries` re-ask (the configured `fallback.message`, default "Sorry, please pick one of the options 😊", re-carrying the options menu); once the budget is spent, run `fallback.after` (end / ai / handoff). A parked run whose snapshot step vanished (legacy edit) simply clears `awaiting` and resumes normally. The idle reaper covers parked runs too (below).
- **Steps snapshot — edits apply to NEW runs only (2026-07-12).** `startRun` freezes the ordered steps into **`whatsapp_flow_runs.steps_snapshot`** and the pacer executes THAT list (`WhatsappFlowRun::dripSteps()`; legacy pre-snapshot runs fall back to the live steps). Editing / reordering / deleting a flow's steps therefore **cannot re-target a conversation already mid-drip** (the cursor is positional — before this, a mid-edit reorder could re-send or skip steps). The edit page shows a banner when N runs are mid-drip; the inbox monitor diagrams the run's snapshot, not the live definition. Flow-level settings (AI profile, objective, idle timeout) remain **live** — they are read fresh at each decision point, so those edits DO affect running conversations.
- **Steps versioning (方案 C — content hash, no new table, 2026-07-12).** `WhatsappFlow::canonicalSteps()` normalises the ordered steps (position, **key**, type, **advance_mode**, delay, body, media, **options**, **fallback**, meta — nested arrays `ksort`ed deep) and is the SINGLE source for both the run snapshot and **`stepsHash()`** (sha1 of the canonical JSON). On every save, `syncStepsVersion` bumps **`steps_version`** ONLY when the hash actually changed (cosmetic saves don't fork a version); `startRun` stamps the run with the flow's current version + hash. **The 2026-07-13 interactive columns changed the hash contract** — every flow re-versions once on its next save (harmless; expected). Legacy runs with a null version display as "Legacy".
- **Runs view (`flows/{id}/runs`).** The flow edit page's "Conversations" button opens a per-flow runs page: **version filter cards** (per-version RUNNING counts; the current version tagged), a §14 DataTable of runs (contact / started-by / status / version / ended reason, whitelisted sort via `FlowRunQueryRequest`), an expandable per-row **snapshot diagram** (the exact frozen steps that run executes — wait/options included), a **Stop** action, and a **deep-link into the Inbox** (`?conversation={uuid}&from_flow={flow}` — the Inbox shows a back-to-flow affordance) for reading the full conversation.
- **Send-result truth (2026-07-12).** `SendWhatsAppMessage` writes each drip step's real provider outcome back onto the run (`meta.step_results`), and window-skips land in `meta.skipped_steps` — `FlowPresenter::progress` now emits per-step `states` (sent ✓ / **failed** rose / **skipped** amber / pending) so the monitor never shows a failed opener as green. A **proactive** run whose OPENER (step 0) fails is ended immediately (`send_failed`, logged) — its window never opened, the rest of the drip could only die silently. Ended runs are visible on the flow edit page's **Recent runs** table (contact / started-by / ended reason) — previously `ended_reason` had zero UI.
- **Proactive dispatch (`FlowDispatcher`).** All bulk proactive starts (sheet webhook + campaign callers) go through `Src\Whatsapp\Services\FlowDispatcher::dispatch($flow, $rows)` — it owns the per-recipient stagger (`StartProactiveFlow::spacingSeconds()`) on the capped `redis-broadcast` lane, so a caller can no longer forget to pace.
- **Per-run variables (free-form personalisation).** `whatsapp_flow_runs.variables` is a schema-less json bag captured when the run starts — a campaign dispatch per recipient (`StartProactiveFlow`'s `$variables` arg) or a Google Sheet row (every sheet column doubles as a variable). It renders into **every step body and template `body_params`** as `{{key}}` (template params are additionally **whitespace-collapsed** — Meta rejects params containing newlines / tabs / 4+ consecutive spaces), and is surfaced to the flow's **AI takeover** as a "Known details about this customer" context block in the system prompt (`WhatsappAiConfig::activeFlowConfig` → `systemPrompt`). This makes the flow module reusable from ANY module — e.g. subsale owner outreach passing `unit_no` / `floor_plan` / `project`.
- **Campaign trigger (`TRIGGER_CAMPAIGN`, API-started).** A third trigger type for flows another module starts programmatically — no keyword, no sheet. Dispatch one paced job per recipient: `StartProactiveFlow::dispatch($flowId, ['phone' => …, 'name' => …], ['unit_no' => …, 'project' => …])->delay(now()->addSeconds($i * StartProactiveFlow::spacingSeconds()))` — the shared `spacingSeconds()` helper (derived from `broadcast.throttle_per_minute`) is the one pacing rule for the sheet webhook and every campaign caller, and the job itself rides the capped single-process `redis-broadcast` lane, so a bulk "send to all owners" is always a **slow blast on BOTH providers** (the Bridge especially — cold bulk on Baileys is the #1 ban vector). The job accepts any `PROACTIVE_TRIGGERS` flow (a keyword flow is deliberately NOT cold-startable — that would bypass its keyword semantics); `meta.started_by` = `campaign` / `sheet`. On **Cloud** every step that must land outside the 24h window (the opener, a day-3/day-7 follow-up) must be a **TYPE_TEMPLATE** step; the Bridge can send free-form. Opted-out (STOP) / blocked contacts are still skipped — the one guard a proactive send always respects; consent is otherwise not gated (all-agree default, per product decision).
- **AI takeover (drip → AI phase) — REACTIVE.** When the last step is sent: a flow **with** an AI profile enters its **AI phase** (`drip_completed_at` stamped); a flow **without** one ends (`drip_done`). Crucially the AI is **reactive, never proactive** — `AdvanceFlowRun::engageAi` only answers a message the customer sent **DURING** the drip (an inbound newer than the run's first drip step); it **never re-answers the trigger** that started the flow (the drip was already its reply — otherwise the bot would "talk to itself" right after the scripted messages). If the customer said nothing new, the run just **waits** in its AI phase and the AI answers their **next** message via the flow-aware `considerAiReply`. (A pure-AI flow with **no** drip steps has no boundary, so it engages the trigger immediately — there the AI *is* the reply.) **Design tip:** end a drip with a question so the customer naturally replies and the AI picks up. The AI phase **reuses the existing `GenerateAiReply` engine unchanged** — `WhatsappAiConfig::resolveFor($channel, $conversation)` is now **flow-aware**: when the conversation is in a flow's AI phase it returns the **flow's** profile (+ the flow's `objective` folded into the system prompt) instead of the channel's default. So burst-merging, anti-ban guards, bubble splitting, the typing indicator and `DripAiReply` are all inherited — the flow engine adds zero AI logic.
- **AI engagement is blind to the flow's own drip.** The takeover reuses `GenerateAiReply`'s normal guards — *latest-message-only* (`engagementSkipReason` / `answeredByNewerOutbound`) and the multi-bubble *barge-in* check (`DripAiReply::supersededByNewerActivity`) — which both treat a **newer outbound** as "already handled / a human jumped in". A drip-then-AI flow ALWAYS leaves its scripted steps newer than the trigger, so those checks **explicitly skip any outbound carrying `meta.flow`** (a drip step is not a reply to the customer, nor a human barge-in). Without this the AI would silently skip every post-drip takeover (`already_answered`) or abort its bubble drip. The pre-send freeform check is provider-correct too: **Sandbox and Bridge are free-form anytime** (`WhatsappConversation::canSendFreeform()` only gates the **Cloud** 24h window), so the sandbox tester never trips `window_closed`. `GenerateAiReply` resolves `resolveFor($channel, $conversation)` **with the conversation** so the takeover always uses the flow's profile + objective, never the channel default.
- **AI suppression during drip.** `considerFlow()` returns a flag; while a run is in its **drip phase** the default `considerAiReply()` is skipped entirely (the drip is the reply). In the **AI phase** it returns false so the normal AI path runs — with the flow profile, via the flow-aware `resolveFor`.
- **Objective auto-detection.** When a flow carries an `objective`, the AI's system prompt (`WhatsappAiConfig::systemPrompt`) asks the model to end its reply with a **`[[OBJECTIVE_MET]]`** token once the goal is genuinely reached. `GenerateAiReply` **strips the token** (it never reaches the customer — parsed exactly like the `[[NEXT]]` bubble delimiter, so no new AI plumbing) and, on an **auto-sent** reply, calls `WhatsappFlowRepository::completeObjective()` which **ends the run** (`objective`); a draft never triggers it (nothing was sent). *(Cross-flow chaining/routing/branches were removed 2026-07-13 — an ended run simply returns the conversation to the channel's default AI.)*
- **Ending a run.** A run ends on: **admin Stop** (the inbox "Stop flow" button / the Runs page → `endRun(stopped)`), **idle timeout** (`whatsapp:reap-flow-runs`, scheduled every minute), a **handoff keyword or menu option** (the customer asks for a human → `endRun(handoff)` + the conversation is handed off so even the default AI stays paused), the **AI objective being reached** (`objective`, see above), the **tree finishing** — the drip's last AUTO step with no AI profile, or a menu option / fallback targeting `end` (`drip_done`), the flow's **template becoming unavailable at send time** (`template_unavailable` — PAUSED/REJECTED/deleted since activation), or a **proactive opener failing to send** (`send_failed`). (`routed` remains only as a legacy label on pre-2026-07-13 rows.) A mid-drip closed Cloud window no longer ends the run — the free-form step is **skipped** and the walk continues (`window_closed` likewise remains only as a legacy label). On end, the conversation falls back to the channel's default AI profile (or AI-off); `endRun` writes a structured log line, and the flow edit page's Recent runs table + the Runs page show every reason.
- **AI phase × closed window (2026-07-12).** The window check in `GenerateAiReply` now applies only to a reply that will actually SEND: **MODE_DRAFT (and auto-downgraded-to-draft) replies are exempt** — when the window closes, the AI still drafts an answer for the admin to send via template, instead of the customer's question being silently swallowed. An AUTO reply skipped on `window_closed` leaves the run RUNNING (parked); the inbox flow monitor shows an amber "AI paused — window closed" note, and the next customer inbound reopens the window and the AI resumes by itself.
- **Reaper backstop (`whatsapp:reap-flow-runs`).** Every minute: ends idle AI-phase runs **AND idle runs parked at an interactive menu** (a customer who never answers — both use the flow's `idle_timeout_minutes`, hard-capped at `MAX_AI_PHASE_MINUTES` = 7 days even when "never" is configured, so a run can't zombie a conversation), and **re-dispatches a stalled drip** whose `next_step_at` is overdue (a lost `AdvanceFlowRun` job) — so a run never hangs. The drip's per-step idempotency makes a resume safe.
- **Proactive Google Sheet trigger (Phase 3).** A flow's `trigger_type` can be **`TRIGGER_SHEET`** instead of keyword; it gets a secret `sheet_token`, and the customer's **Google Apps Script** (see below) POSTs new lead rows to the public **`POST /webhooks/whatsapp/sheet/{token}`** (`WhatsAppWebhookController::handleSheet`). Each row is queued onto the capped single-process **`redis-broadcast`** lane and **spread across time** (`broadcast.throttle_per_minute`), so a bulk import can NEVER burst. **`StartProactiveFlow`** then normalises the phone, find-or-creates the WhatsApp contact (+ a CRM lead, best-effort via `LeadRepository::firstOrCreateForPhone`), **SKIPS an opted-out / blocked contact** (the one consent gate a proactive send always respects — the chosen safety stance), opens the conversation and starts the flow. The flow's **first step is the proactive opener**: on **Cloud** it must be a **`TYPE_TEMPLATE`** step (an approved Meta template — free-form is blocked outside the 24h window, which the template step is EXEMPT from); on **Bridge** it can be free-form. After the opener the run waits (AI phase) for the customer's reply, then the normal engine (drip / AI takeover / menu options) runs. Both providers are allowed (the user's call), but every proactive send is paced — bulk Cloud-only broadcasting stays the broadcast module's job.
- **Admin UI (§14 + builder).** `Flows/Index.vue` is a standard admin index (DataTable + search on name + channel/status FilterDrawer with counts + whitelisted sorting via `FlowQueryRequest` + `ResolvesListQuery`): "New flow" opens `FlowFormModal` (**type picker** (Rule-based bot / Time-based drip) + name + channel — create only; type and channel are fixed afterwards; the Index rows carry a violet/sky **type badge**), row actions = Edit / Activate-Deactivate / Delete. Each flow's **edit page** (`Flows/Edit.vue`, smart back-URL via `ResolvesBackUrl`, mirroring the AI profile page) wraps the shared **`Flows/Partials/FlowForm.vue`** — the trigger (keyword chips + match mode + case-sensitive + priority · Google Sheet · Campaign API), the **ordered step builder** (add text / media / template, per-step delay as **value + unit** (seconds/minutes/hours/**days** — the label shows *relative to the previous message* AND the *cumulative time from start*, since delays are sequential) + body + media upload via a silent XHR to `FlowsController@uploadMedia`, reorder, remove), and the **AI takeover** (profile select + objective + idle timeout). Beside the builder on `lg+` is a sticky **read-only live preview** (the shared `Components/Whatsapp/FlowDiagram.vue` fed from the form state) that illustrates the flow as a timeline as you edit. **Activating guards** against a flow that could never fire (a keyword flow with no keyword, or any flow with no step and no AI profile); the **Activate button auto-saves unsaved edits first** (`FlowForm` exposes `save()` / `isDirty()` to `Edit.vue`) so a just-typed keyword is persisted before the guard reads the DB.
- **Type-tailored forms (2026-07-14).** The whole edit form adapts to `flow_type` (`FlowForm` `isBot`): a **Rule-based bot** shows the WAIT/options editor on TEXT steps but **no delay row** (every send immediate — delays forced to 0 in the client transform AND server-side in `FlowsController::mapSteps`), no "Add template" button, and **keyword-only trigger tabs** (Sheet/Campaign are drip-only, still shown for legacy data); a **Time-based drip** shows delays + the 24h-window hints but **no wait/options UI** (options stripped in the transform and in `mapSteps`). Both keep the same live preview/graph.
- **Builder redesign (2026-07-12, extended 2026-07-13).** The edit page is channel-aware + progressively disclosed: a provider badge in the identity header; **"Add template" only on Cloud** flows; a Cloud **24h-window divider** in the step list (amber dashed line before the first step whose cumulative delay passes 24h) with per-step amber "outside window — skipped unless the customer replies" / emerald "template — allowed outside the window" chips; a rose hard-error banner for a proactive Cloud flow whose opener is not a template (mirrors the activate guard); a rose ring on legacy Bridge template steps ("convert to text or delete"). Every TEXT step has a **"Wait for the customer's reply" toggle** revealing the violet interactive editor: an **options list** (label + target select over the OTHER steps by `step_key`, labelled "Message N · preview", plus **➕ New message…** — picking it CREATES the option's next text message right below the menu step and wires the option to it, so the whole answer→message→answer tree is built from the options themselves — plus End / AI / Handoff; a dangling target after a message is deleted shows as "⚠ Missing message"; ≤10 options; a Cloud note explains buttons-vs-list truncation) and the **fallback row** (re-ask message, retries 0–5, then end/AI/handoff). Step cards referenced by menu options carry **"↳ from Message N · label" chips**, so the flat list reads as a tree. Step keys are minted client-side for new steps (`newStepKey()`), so targets stay valid across reorders before the first save; `UpdateRequest` cross-validates every option target against the submitted keys. The **AI takeover card has an ON/OFF toggle** gating the whole AI group (profile, idle timeout, objective); toggling OFF **clears those fields on save** (values stay in local state until then, with an amber warning when saved values would be lost — the engine's only truth is `ai_profile_id`, there is no separate disabled flag). Server 422s surface in a top error summary listing every nested `steps.*` error. No-permission users get a proper read-only `<fieldset disabled>`. A collapsible **Recent runs** table below the form shows each run's contact / started-by / status / ended reason.
- **Graph view (`FlowGraph.vue` + `FlowView.vue`, 2026-07-13).** Every flow visual now has a **Graph ⇄ List toggle** (`Components/Whatsapp/FlowView.vue`; the choice lives in the shared `composables/useFlowViewPref.js` singleton — one toggle updates every instance on the page, persists in localStorage `wa-flow-view` with a throw-safe read, and until the user ever toggles each surface shows its own `defaultView` — the narrow w-72 inbox panel opens as List, wide pages as Graph): **Graph** renders the flow as a read-only node-canvas (PREMO/Coze-style) via **Vue Flow** (`@vue-flow/core` + Background/Controls) with **dagre auto-layout** (`@dagrejs/dagre` — nothing persisted, no coordinates stored): Trigger node → message nodes (state-tinted like the list view; a menu step gets a violet chip) → AI / **End** / **Human handoff** terminal nodes. Edges carry meaning: sequential edges are labelled with the **delay** ("3 days"), a WAIT step's **options fan out as violet labelled edges** to their targets (backward "main menu" loops draw naturally), its **fallback is a dashed edge** ("no match ×2"), and the AI node has a dashed "objective met / idle" edge to End; parallel edges to the SAME target (e.g. an option ending the flow + the fallback) are merged into one edge with the labels joined ("No · no match ×2") so nothing overprints. The canvas is pan/zoom (wheel-zoom off so page scroll isn't hijacked; Controls buttons zoom), and `FlowGraph.vue` is **lazy-loaded** (`defineAsyncComponent` → own Vite chunk, ~71 kB gzip, downloaded only when a graph is first shown). The graph consumes the same `{flow, progress}` contract as the list — `FlowPresenter::displayOptions` additionally emits the machine-readable `target_kind` (`step|end|ai|handoff`) + `target_position`, and each WAIT step carries a normalised `fallback {max_retries, after}`. Used in all three visual sites: the edit-page live preview (TB layout), the Runs page snapshot rows (LR), and the inbox monitor (TB).
- **Flow diagram + inbox monitor (`FlowDiagram.vue`).** One shared component renders a flow as a vertical timeline — trigger → each message (icon by kind + one-line preview + the delay line; a **menu step** gets a violet "menu" chip + its **options fan-out** "label → Message N / AI takeover / Human handoff / End flow", and the delay line after it reads "after the customer picks an option") → AI takeover (objective / idle). It takes a normalized shape built by **`FlowPresenter::diagram($flow, ?$run)`** (server) or client-side from the edit form. With an active `$run` it also takes a **`progress`** block (`phase`, `current_step`, `sent_count`, `total_steps`, `next_step_at`, per-step real `states`) and marks each step **sent ✓ / failed / skipped / current ("next in Nd", or "awaiting reply" on a parked menu) / pending**, and the AI node "handling now" in the AI phase — so an admin sees exactly where a conversation has reached.
- **Inbox integration.** The open conversation exposes **`active_flows`** — EVERY running run (bot first), each `FlowPresenter::diagram($run->flow, $run) + {run_id, phase, flow_type, type_label}` — the full flow shape *plus this conversation's progress* per run. (The legacy singular `active_flow` prop was removed 2026-07-14.) `Inbox.vue`'s thread header shows a violet **flow chip** with a compact status (`{name} · step 2/4` or `· AI handling`); clicking it expands a **monitor panel** hosting `<FlowDiagram :flow :progress>` (the step-by-step "you are here" timeline) with a **Stop flow** button inside (optimistic silent XHR to `conversations/{id}/stop-flow`). A menu step's outbound bubble renders its **options as choice chips** (`MessagePresenter` exposes `menu` from `meta.interactive.options`). Sits alongside the existing Pause-AI / Block controls; hidden for group threads.
- All writes go through `WhatsappFlowRepository` inside `DB::transaction`; controllers stay thin and return `Inertia::render` / `back()` (the media upload is JSON for the builder).

## Phased plan
- **Phase 1 — Reactive keyword flow. ✅ SHIPPED** (this document).
- **Phase 2 — Objective auto-detection. ✅ SHIPPED.** The AI signals completion with a `[[OBJECTIVE_MET]]` token; the run ends (`objective`). *(The chaining half of Phase 2, and all of Phase 2.5 — `[[GOTO]]` AI routing + deterministic `branches` — were REMOVED 2026-07-13 in the interactive-tree restructure: in-flow menu options replace cross-flow jumps.)*
- **Phase 3 — Proactive Google Sheet trigger. ✅ SHIPPED.** A new sheet row (via Apps Script webhook) → create contact + CRM lead → start the flow's proactive opener (Cloud template / Bridge free-form), **paced** on the broadcast lane and **skipping opted-out contacts**. Both providers allowed (per the user); the Cloud-only restriction stays a *broadcast* rule. Lead *enrichment* (beyond the thin lead) and per-channel quality gating on the proactive path remain future polish. **Ops note:** `StartProactiveFlow` rides the **`redis-broadcast`** lane (`supervisor-broadcast`, now activated in `config/horizon.php` `environments.{production,local}` — see [broadcast.md](/docs/modules_handbook/manage/messages/whatsapp/broadcast.md) → *Prerequisites*); confirm a `horizon:work redis-broadcast` worker after a restart or proactive starts queue but never run. Reactive (keyword) flows are unaffected — they run on the default lane.

## GUIDELINES alignment
- **§2/§3** — all writes via `WhatsappFlowRepository` in `DB::transaction` (create/update/setStatus/delete + the run lifecycle startRun/advanceRun/completeDrip/touchCustomer/endRun); thin controller with explicit input mapping; validation in Form Requests.
- **§3 constants** — `STATUS_*`/`STATUSES`, `TRIGGER_*`, `MATCH_*`, step `TYPE_*`, run `STATUS_*`/`ENDED_*`; no magic numbers.
- **§7** — key model (`WhatsappFlow`: uuid + blame + soft delete) + two child tables (no uuid), snake_case ≤30-char columns, indexed FKs, no schema FKs; new migrations only.
- **§14** — `FlowQueryRequest` extends `ManageQueryRequest`; `FlowsController` uses `ResolvesListQuery` + `ResolvesBackUrl`; the index is config + shared `DataTable`/`FilterDrawer`/`ConfirmModal`.

## Google Apps Script (Phase 3 — sheet ingestion)
Paste this into the customer's sheet (**Extensions → Apps Script**), set `WEBHOOK_URL` to the flow's webhook URL (shown on the flow edit page), map the column header names, then add an **installable trigger** (On form submit, or On change) calling `onNewRow`. Each row needs at least a phone column.

```javascript
const WEBHOOK_URL = 'https://YOUR-APP/webhooks/whatsapp/sheet/YOUR-TOKEN';
const COLS = { name: 'Name', email: 'Email', phone: 'Phone' }; // your sheet's header names

function onNewRow(e) {
  const sheet = e.range.getSheet();
  const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
  const row = sheet.getRange(e.range.getRow(), 1, 1, sheet.getLastColumn()).getValues()[0];
  const get = (h) => { const i = headers.indexOf(h); return i === -1 ? '' : row[i]; };
  const payload = { name: get(COLS.name), email: get(COLS.email), phone: get(COLS.phone) };
  if (!payload.phone) return;
  UrlFetchApp.fetch(WEBHOOK_URL, {
    method: 'post', contentType: 'application/json',
    payload: JSON.stringify(payload), muteHttpExceptions: true,
  });
}
```
The endpoint also accepts a batch — `{ "rows": [ {name, phone}, … ] }` — for a backfill (capped at 500/request).

## Testing without a real WhatsApp number (Sandbox demo)

- **Dedicated Sandbox page** — testing lives at **`/manage/messages/sandbox`** (sidebar → *WhatsApp Automation → Sandbox*), NOT the real inbox, so test threads never confuse admins. It is the **same `Inbox.vue` + `InboxController`** as the real inbox, driven by a `mode` flag: `InboxController::sandbox` renders `mode:'sandbox'` scoped to sandbox channels; `index` renders `mode:'inbox'` scoped to **non**-sandbox channels (the real inbox now excludes sandbox). One component, one controller, zero duplication.
- **Seed a ready scenario** — `php artisan db:seed --class='\WhatsappDemoSeeder'` (global-namespace seeder, so the **leading backslash** is required) creates a **Sandbox channel** ("Sandbox Demo") + an AI profile ("Demo Sales AI", `MODE_AUTO`, assigned as the channel default) + an ACTIVE keyword flow ("Demo Welcome Flow": keywords *hi/hello/start/enquire* → a 2-step text drip → the AI profile, objective = *book a Mont Kiara Residences viewing*) + a "Demo Customer" conversation. Idempotent.
- **Drive + trace it** — `php artisan whatsapp:flow-test "hello, can I book a viewing?"` injects that text through the **same inbound pipeline** the real webhook uses, then **watches the thread** for ~30s and prints every message as it lands (drip steps + the AI bubbles) plus the flow-run state and a diagnosis (drip count, AI count, any skip reason / draft). Needs **Horizon running** (the drip pacer + AI reply are queued). `--sync` runs the inbound + AI inline for a Horizon-free trace (single-step drips only — `AdvanceFlowRun`'s `WithoutOverlapping` blocks a same-run nested step under the sync driver). Same engine as the inbox **"send as customer"** bar, so a fix proven here holds in the live inbox.
- **Pairing tester (flow × profile, many conversations).** One sandbox channel holds **many pinned test conversations**, each a `(flow, ai_profile)` pairing stored on `whatsapp_conversations.test_config` (`{flow_id, ai_profile_id}`; `WhatsappConversation::isSandboxTest()`). The inbox list's **"New sandbox test"** button (`SandboxTestModal` → `InboxController::storeTestConversation` → `WhatsappRepository::createTestConversation`) creates one — pick a flow (or "no flow") + an AI profile (or "the flow's own"). Two engine hooks honour the pin: **`WhatsappAiConfig::resolveFor` / `activeFlowConfig`** apply the pinned profile as an **override** of the flow's own during the AI takeover (keeping the flow's objective), and **`ProcessInboundWhatsAppWebhook::matchingFlow`** scopes keyword triggering to the **one** pinned flow (a "no flow" pin never triggers; `AdvanceFlowRun::completeDrip` also enters the AI phase for a pure-drip flow when an override is pinned, so even a scriptless flow can hand to any AI). A **reactive** pinned flow triggers by typing its keyword; a **proactive** one starts via the bar's **"Start flow"** button (`conversations.start-flow` → `InboxController::startTestFlow`). The seeder ships three example pairings (Flow+Demo Sales AI, Flow+Concise AI, No-flow+Demo Sales AI) so the same drip → two different AI voices is visible immediately.
- **Dev gotcha (native Windows).** Run the troubleshooter under **WSL**, not native Windows: inspector-apm's async transport (`proc_open`) chokes on the large telemetry payload of an inline cascade ("the command line is too long"), and the inbox **Reverb** broadcaster on `:8080` must be up or queued `NewWhatsAppMessage`/`WhatsAppMessageStatusUpdated` jobs fail (harmless to the flow, but noisy). After changing any job class, **`php artisan horizon:terminate`** so the worker reloads the new code (`php artisan horizon` does not hot-reload).

## Related files

**Backend — Models**
- [src/Whatsapp/WhatsappFlow.php](/src/Whatsapp/WhatsappFlow.php) — the flow (key model); `STATUS_*` / `TRIGGER_*` / `MATCH_*` / `MEDIA_COLLECTION`; `matchesText()` keyword matcher; **`canonicalSteps()` / `stepsHash()`** (the versioning + snapshot source); `channel()` / `aiProfile()` / `steps()` / `runs()` / `media()`.
- [src/Whatsapp/WhatsappFlowStep.php](/src/Whatsapp/WhatsappFlowStep.php) — an ordered step (child); `TYPE_TEXT` / `TYPE_MEDIA` / `TYPE_TEMPLATE` (meta.template = {name, language, body_params, header?, buttons?}); **`ADVANCE_AUTO` / `ADVANCE_REPLY`** + `waitsForReply()` + **`TARGET_END` / `TARGET_AI` / `TARGET_HANDOFF`** (menu option targets); `step_key` / `options` / `fallback`; `media()`.
- [src/Whatsapp/WhatsappFlowRun.php](/src/Whatsapp/WhatsappFlowRun.php) — the per-conversation state machine (child); `STATUS_*` / `ENDED_*` (incl. `template_unavailable` / `send_failed`); `inDripPhase()` / `inAiPhase()`; **`steps_snapshot`** + `dripSteps()` (frozen at start) + `skippedSteps()` / `stepResults()` (monitor truth).
- [src/Whatsapp/Support/TemplateStepValidator.php](/src/Whatsapp/Support/TemplateStepValidator.php) — the ONE template-step judge shared by save / activate / send / sandbox (Bridge ban, exists+APPROVED+non-Auth on the channel registry, param counts vs distinct `{{n}}` / header / URL-button variables).
- [src/Whatsapp/Services/FlowDispatcher.php](/src/Whatsapp/Services/FlowDispatcher.php) — the single paced entry point for bulk proactive dispatches (sheet + campaign callers).
- [app/Console/Commands/AuditFlowTemplates.php](/app/Console/Commands/AuditFlowTemplates.php) — `whatsapp:flows-audit` (one-shot: list legacy flows the channel-aware guards would reject).
- [resources/js/utils/templateComponents.js](/resources/js/utils/templateComponents.js) · [Flows/Partials/FlowTemplatePicker.vue](/resources/js/Pages/Manage/Messages/Flows/Partials/FlowTemplatePicker.vue) — component parser (distinct `{{n}}`, header/button variables) + the approved-template picker with param inputs + `TemplatePreview` bubble.

**Backend — Repository, Presenter, Engine**
- [src/Whatsapp/Repositories/WhatsappFlowRepository.php](/src/Whatsapp/Repositories/WhatsappFlowRepository.php) — flow CRUD (steps replaced as a group; `replaceSteps` mints missing `step_key`s; `syncStepsVersion`) + the run lifecycle writes (`startRun` / `advanceRun` / **`jumpRun`** (hop++) / **`setAwaitingReply`** / **`bumpFallbackAttempts`** / **`clearAwaiting`** / `completeDrip` / `endRun`) + `completeObjective()` (ends the run).
- [app/Jobs/Whatsapp/GenerateAiReply.php](/app/Jobs/Whatsapp/GenerateAiReply.php) — the shared AI engine; resolves `resolveFor($channel, $conversation)` (flow-aware takeover); `OBJECTIVE_TOKEN` strip + `completeFlowObjective()`; `answeredByNewerOutbound()` **ignores `meta.flow` drip steps** so the post-drip takeover engages.
- [app/Jobs/Whatsapp/DripAiReply.php](/app/Jobs/Whatsapp/DripAiReply.php) — paces a multi-bubble AI reply; `supersededByNewerActivity()` **ignores the flow's own `meta.flow` drip** (only a real human/agent outbound aborts the bubble drip).
- [src/Whatsapp/WhatsappConversation.php](/src/Whatsapp/WhatsappConversation.php) — `canSendFreeform()` gates only the **Cloud** 24h window; **Bridge + Sandbox are free-form anytime**. Sandbox pairing tester: `test_config` json + `isSandboxTest()` / `testFlowId()` / `testAiProfileId()`.
- [app/Jobs/Whatsapp/StartProactiveFlow.php](/app/Jobs/Whatsapp/StartProactiveFlow.php) — paced proactive start for ANY `PROACTIVE_TRIGGERS` flow (sheet + campaign; contact + CRM lead, skip opted-out, open conversation, start flow **with the `$variables` bag**; `spacingSeconds()` = the shared caller pacing rule) · [app/Http/Controllers/Webhooks/WhatsAppWebhookController.php](/app/Http/Controllers/Webhooks/WhatsAppWebhookController.php) `handleSheet` — the public sheet ingestion endpoint (the row doubles as the variables bag).
- [src/Whatsapp/Support/FlowPresenter.php](/src/Whatsapp/Support/FlowPresenter.php) — flow → edit-page array (steps with signed media URLs + key/wait/options/fallback for the builder) + **`diagram($flow, ?$run)`** (the `FlowDiagram` timeline shape + optional run `progress`) + `runRow()` (the Runs page row incl. the frozen snapshot diagram).
- [src/Whatsapp/Support/MessagePresenter.php](/src/Whatsapp/Support/MessagePresenter.php) — inbox bubble shape; **`menu`** (a menu step's options for the choice chips) + `flow` (name / step / run for the violet tint + monitor link).
- [app/Jobs/Whatsapp/AdvanceFlowRun.php](/app/Jobs/Whatsapp/AdvanceFlowRun.php) — the drip pacer (send step → schedule next / **park at a WAIT step** / complete → engage AI); `menuOptions()` attaches `meta.interactive.options`; hop-aware idempotency.
- [app/Jobs/Whatsapp/SendWhatsAppMessage.php](/app/Jobs/Whatsapp/SendWhatsAppMessage.php) — routes an outbound carrying `meta.interactive.options` to the driver's `sendInteractive` · [src/Whatsapp/Drivers/CloudApiDriver.php](/src/Whatsapp/Drivers/CloudApiDriver.php) (native reply buttons ≤3 / list 4–10) · [BridgeDriver.php](/src/Whatsapp/Drivers/BridgeDriver.php) (numbered text menu) · [SandboxDriver.php](/src/Whatsapp/Drivers/SandboxDriver.php).
- [app/Console/Commands/ReapFlowRuns.php](/app/Console/Commands/ReapFlowRuns.php) — `whatsapp:reap-flow-runs` (the **no-answer reminder ladder** `remindSilentMenus` — re-send → Notify `whatsapp.flow_no_response` → end `no_response`; idle timeout — AI phase AND parked menus, deferring to an active ladder; 7-day hard cap + stalled-drip resume; scheduled every minute).
- [app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php](/app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php) — `considerFlow()` / `matchingFlow()` (trigger + drip suppression + handoff) gating `considerAiReply()`; **`handleAwaitingReply()` / `matchMenuOption()` / `applyMenuTarget()`** (the interactive reply router + fallback engine).
- [src/Whatsapp/Services/WhatsappAiConfig.php](/src/Whatsapp/Services/WhatsappAiConfig.php) — `resolveFor($channel, $conversation)` is flow-aware (`activeFlowConfig()`) and honours a **sandbox pairing override** (`sandboxTestProfile()` — the pinned profile supersedes the flow's own); `systemPrompt()` appends the flow objective. See [ai_profile.md](/docs/modules_handbook/manage/messages/whatsapp/ai_profile.md).
- [app/Http/Controllers/Manage/Whatsapp/InboxController.php](/app/Http/Controllers/Manage/Whatsapp/InboxController.php) — `index`/`sandbox` both call `renderWorkspace($request, $sandbox)` (the real inbox excludes sandbox channels; the `sandbox` page includes only them, with the tester); sandbox pairing tester: `storeTestConversation` (create a pinned test thread), `startTestFlow` (force-start a proactive pinned flow), `sandboxOptions` (flows + profiles for the modal) · [app/Http/Requests/Manage/Whatsapp/StoreTestConversationRequest.php](/app/Http/Requests/Manage/Whatsapp/StoreTestConversationRequest.php) · `WhatsappRepository::createTestConversation`.

**Backend — Controller + Requests + Routes**
- [app/Http/Controllers/Manage/Whatsapp/FlowsController.php](/app/Http/Controllers/Manage/Whatsapp/FlowsController.php) — §14 index + edit/store/update/activate/deactivate/destroy + `uploadMedia` (JSON) + **`runs`** (the per-flow Runs page: version cards + run rows) + **`dispatchRecipients`** ("Send to customers" — activation-guarded, rows sanitised then handed to `FlowDispatcher`); `mapSteps`/`mapStepOptions`/`mapStepFallback` (explicit input mapping incl. the interactive fields, `button_index` and the reminder keys).
- [app/Http/Requests/Manage/Whatsapp/FlowQueryRequest.php](/app/Http/Requests/Manage/Whatsapp/FlowQueryRequest.php) · [FlowRunQueryRequest.php](/app/Http/Requests/Manage/Whatsapp/FlowRunQueryRequest.php) (the Runs page list) · [Flows/StoreRequest.php](/app/Http/Requests/Manage/Whatsapp/Flows/StoreRequest.php) · [Flows/UpdateRequest.php](/app/Http/Requests/Manage/Whatsapp/Flows/UpdateRequest.php) (steps + options/fallback rules incl. `button_index` + the reminder ladder; `withValidator` cross-checks every option target against the submitted step keys, template steps against the registry, and a template MENU's options against the template's QUICK_REPLY buttons) · [Flows/DispatchRequest.php](/app/Http/Requests/Manage/Whatsapp/Flows/DispatchRequest.php) (the bulk send: ≤500 rows, phone per row) · [Flows/UploadMediaRequest.php](/app/Http/Requests/Manage/Whatsapp/Flows/UploadMediaRequest.php).
- [app/Http/Controllers/Manage/Whatsapp/InboxController.php](/app/Http/Controllers/Manage/Whatsapp/InboxController.php) — `active_flows` prop (one `FlowPresenter::diagram($run->flow, $run)` + `{run_id, phase, flow_type, type_label}` per running run — full shape + progress) + `stopFlow`. The legacy singular `active_flow` prop was removed 2026-07-14.

**Frontend (Vue)**
- [resources/js/Components/Whatsapp/FlowView.vue](/resources/js/Components/Whatsapp/FlowView.vue) — the Graph ⇄ List toggle wrapper every page drops in (localStorage-persisted; lazy-loads the canvas) · [resources/js/Components/Whatsapp/FlowGraph.vue](/resources/js/Components/Whatsapp/FlowGraph.vue) — the read-only node-canvas (Vue Flow + dagre auto-layout; option/fallback edges, delay labels, progress-tinted nodes).
- [resources/js/Components/Whatsapp/FlowDiagram.vue](/resources/js/Components/Whatsapp/FlowDiagram.vue) — the shared read-only flow timeline (trigger → steps with menu chips + options fan-out → AI); optional `progress` marks sent / failed / skipped / current ("awaiting reply" on a parked menu) / pending for the inbox monitor. Both views share one `{flow, progress}` contract and are used by the edit-page live preview, the inbox, **and** the Runs page snapshot rows.
- [resources/js/Pages/Manage/Messages/Flows/Index.vue](/resources/js/Pages/Manage/Messages/Flows/Index.vue) — §14 flows index (DataTable + filters + activate/deactivate/delete).
- [resources/js/Pages/Manage/Messages/Flows/Edit.vue](/resources/js/Pages/Manage/Messages/Flows/Edit.vue) — the edit page (identity header + **Send to customers** (proactive flows) + Conversations button + activate/delete; **Activate auto-saves via the form's exposed `save()`/`isDirty()`**) wrapping the form · [Partials/FlowSendModal.vue](/resources/js/Pages/Manage/Messages/Flows/Partials/FlowSendModal.vue) — the bulk-dispatch modal (paste / CSV upload, quote-aware parse, variables + validity + duplicate preview, paced-blast estimate, and a **Download example** button whose CSV header is built from the {{variables}} the flow's saved messages actually reference — Edit.vue scans the steps and passes them in).
- [resources/js/Pages/Manage/Messages/Flows/Runs.vue](/resources/js/Pages/Manage/Messages/Flows/Runs.vue) — the per-flow Runs page (version filter cards + DataTable + expandable snapshot `FlowDiagram` + Stop + Inbox deep-link with the back affordance).
- [resources/js/Pages/Manage/Messages/Flows/Partials/FlowForm.vue](/resources/js/Pages/Manage/Messages/Flows/Partials/FlowForm.vue) — trigger + step builder (**WAIT toggle + options editor + fallback editor** on TEXT steps; client-side `newStepKey()`) + AI takeover, with a sticky **`FlowDiagram` live preview** (`livePreview` computed from the form) · [Partials/FlowFormModal.vue](/resources/js/Pages/Manage/Messages/Flows/Partials/FlowFormModal.vue) — create (name + channel).
- [resources/js/Pages/Manage/Messages/Inbox.vue](/resources/js/Pages/Manage/Messages/Inbox.vue) — the **flow monitor chip** (compact progress → expands a `FlowDiagram` panel with Stop-flow inside) + the sandbox pairing tester (the "New sandbox test" button, the per-conversation pairing chip + "Start flow" button) · [resources/js/Pages/Manage/Messages/Partials/SandboxTestModal.vue](/resources/js/Pages/Manage/Messages/Partials/SandboxTestModal.vue) — pick a flow + profile pairing · [resources/js/Components/Messages/MessagesTabs.vue](/resources/js/Components/Messages/MessagesTabs.vue) — the "Flows" tab (an AI Automation pill on the Messages hub strip; the old ManageLayout nav entries are gone).

**Demo seeder + tooling + tests**
- [database/seeds/WhatsappDemoSeeder.php](/database/seeds/WhatsappDemoSeeder.php) — seeds the Sandbox channel + AI profile + keyword flow + Demo Customer (run with `db:seed --class='\WhatsappDemoSeeder'`).
- [app/Console/Commands/TestWhatsappFlow.php](/app/Console/Commands/TestWhatsappFlow.php) — `whatsapp:flow-test {text} {--watch=} {--sync}`: inject a sandbox inbound, watch the thread, diagnose the flow + AI takeover.
- [tests/Feature/Whatsapp/FlowEngineTest.php](/tests/Feature/Whatsapp/FlowEngineTest.php) — flow-engine suite incl. the **drip-then-AI** regression tests (the AI takes over after a drip; the bubble drip is not aborted by the flow's own steps) and the **interactive WAIT suite** (menu send + park, reply matching by number/label/native id, fallback re-ask → after action, AI/handoff targets, hop re-send on a revisited menu, step-key minting) · [FlowTemplateGuardTest.php](/tests/Feature/Whatsapp/FlowTemplateGuardTest.php) — the channel-aware template guards · [FlowTemplateMenuTest.php](/tests/Feature/Whatsapp/FlowTemplateMenuTest.php) — the proactive template MENU (payload buttons + park + Cloud button-tap routing), the no-answer reminder ladder (+ Notify) and the bulk dispatch endpoint.

**Migrations**
- [database/migrations/2026_06_30_000006_create_whatsapp_flows_table.php](/database/migrations/2026_06_30_000006_create_whatsapp_flows_table.php) · `_000007_create_whatsapp_flow_steps_table.php` · `_000008_create_whatsapp_flow_runs_table.php` · `_000012_add_sheet_token_to_whatsapp_flows_table.php` (Google Sheet trigger) · [2026_07_02_000030_add_variables_to_whatsapp_flow_runs_table.php](/database/migrations/2026_07_02_000030_add_variables_to_whatsapp_flow_runs_table.php) (per-run `variables` bag). *(The `_000009`/`_000010`/`_000011` cross-flow columns — next_flow_id / routing_hint / branches — were dropped 2026-07-13.)*
- [database/migrations/2026_07_01_000010_add_test_config_to_whatsapp_conversations_table.php](/database/migrations/2026_07_01_000010_add_test_config_to_whatsapp_conversations_table.php) — `test_config` json (the sandbox pairing pin).
- [database/migrations/2026_07_12_000001_add_steps_snapshot_to_whatsapp_flow_runs_table.php](/database/migrations/2026_07_12_000001_add_steps_snapshot_to_whatsapp_flow_runs_table.php) — `steps_snapshot` json (runs execute their start-time step list; edits apply to new runs only) + the 2026-07-12 `steps_version`/`steps_hash` columns on flows + runs (content-hash versioning).
- [database/migrations/2026_07_13_100001_add_interactive_columns_to_whatsapp_flow_steps_table.php](/database/migrations/2026_07_13_100001_add_interactive_columns_to_whatsapp_flow_steps_table.php) — `step_key` / `advance_mode` / `options` / `fallback` (interactive WAIT steps).
- [database/migrations/2026_07_13_100002_drop_cross_flow_jumps_from_whatsapp_flows_table.php](/database/migrations/2026_07_13_100002_drop_cross_flow_jumps_from_whatsapp_flows_table.php) — drops `branches` / `routing_hint` / `next_flow_id`.

**Routes**
- [routes/web.php](/routes/web.php) — `manage.messages.flows.*` (incl. `flows.runs` — declared before `GET {id}`) + `manage.messages.conversations.stop-flow` + the sandbox pairing tester (`conversations.test-create` / `conversations.start-flow`).
