# WhatsApp — AI Integration

**Scope:** the AI layer of the WhatsApp inbox — knowledge + settings (global & per-channel) and the reply engine (auto-reply / draft suggestions). Built ON TOP of the shared [AI module](/docs/modules_handbook/shared/ai/readMe.md) (`Src\Ai`): keys, transports, prompt registry, request logging and the resilient `AiJob` queue lane all come from there — this feature adds the WhatsApp-specific configuration, engagement rules and delivery.

## What it does

The model has three pieces:

- **Global base** — shared **knowledge documents + a base instruction** (identity, tone, rules). No behaviour of its own; it's a library that profiles can pull in.
- **AI Profiles** — reusable, **named** assistant configs that carry the behaviour (mode, provider/model), their own documents + instruction, and an **`extends_global`** flag (when on, the global documents + instruction are layered in underneath their own). A profile is **assigned to many channels**.
- **Channels** — each channel is assigned to **one profile** (`whatsapp_channels.ai_profile_id`) or **none** (null = AI off for that number). Assignment IS the on/off switch — there is no separate per-profile enable flag (an unassigned channel resolves to disabled; an assigned one is active).

The engine then handles inbound customer messages in one of two modes:

- **Suggest drafts (`MODE_DRAFT`)** — the AI writes a reply and shows it **above the composer** ("✨ AI suggested reply" with **Use** / dismiss); the agent stays in control.
- **Auto-reply (`MODE_AUTO`)** — the AI's reply is sent to the customer automatically through the normal outbound pipeline (echo bubble, ticks, failure visibility — indistinguishable plumbing-wise from an agent send).

## The settings layer (`/manage/messages/ai`)

**Model: `WhatsappAiProfile`** (`whatsapp_ai_profiles`) — one `is_global = true` base row (documents + `instruction` only) + named profile rows (`is_global = false`).

| Field (profiles) | Meaning |
|---|---|
| `name` | profile name |
| `extends_global` | layer the global documents + instruction under this profile's own |
| `mode` | `MODE_DRAFT` (1) / `MODE_AUTO` (2) |
| `debounce_seconds` | **reply settle window**: the AI waits this long after the customer's LAST message before generating, so a rapid multi-message burst is answered once, completely (null → `DEFAULT_DEBOUNCE_SECONDS` = 10; 0 = instant; ≤ 120) |
| `auto_reply_start` / `auto_reply_end` | **auto-reply active hours** (`HH:MM` wall-clock, evaluated in `config('whatsapp.ai.auto.timezone')`, which always follows the app timezone `APP_TIMEZONE`). Both null = reply **24 hours**; otherwise a **MODE_AUTO** reply outside the window falls back to a **draft** for a human (an end earlier than the start = an overnight window). Set per-profile on the edit page (a "24 hours" toggle + From/To time pickers). |
| `instruction` | this profile's instruction (merged after the global one when extending) |
| `provider` / `model` | null = AI-module defaults (`config('ai.default_provider')` / catalog `default_model`) |

> **Bubble splitting (fixed).** The AI **always splits its reply by topic into separate WhatsApp bubbles** (a single-topic reply or greeting stays one message; cap `MAX_BUBBLES` = 4). In auto mode the bubbles trickle out with a typing indicator (`DripAiReply`); in draft mode they are joined into one editable suggestion.

> **Engagement is NOT unconditional (anti-ban guards).** The AI engages on every inbound message after the settle window, **but** several guards keep MODE_AUTO from looking like a banned 24/7 general-purpose bot (the pattern Meta restricts accounts for). A blocked or AI-opted-out contact is skipped entirely; otherwise a would-be auto-send **falls back to a draft** (a human reviews it) when any guard trips — see *Auto-reply safety guards* below. MODE_DRAFT is always human-gated and unaffected.

### Auto-reply safety guards (anti-ban)

Because Meta restricts accounts whose Cloud number behaves like a fully-automated / general-purpose LLM bot — and because the unofficial Baileys number is even more exposed — **MODE_AUTO is bounded**. The base prompt ([resources/prompts/whatsapp_assistant.md](/resources/prompts/whatsapp_assistant.md)) now also tells the assistant to **disclose that it is automated** when asked and never to impersonate a human.

The guards live in `config('whatsapp.ai')` (global defaults; per-profile active-hours is the one exception, stored on the profile) and are enforced in **`GenerateAiReply`** (`autoDowngradeReason()` / `withinAutoCooldown()`) and the dispatch gate **`ProcessInboundWhatsAppWebhook::considerAiReply()`**:

| Guard | Config key | Effect when it trips |
|---|---|---|
| **Blocked contact** | (`contact.blocked_at`) | **skip** — no AI reply (a human can still send); `MessagesController` also blocks human sends to a blocked contact |
| **Human handoff (keyword)** | `whatsapp.ai.handoff_keywords` | an inbound TEXT exactly matching a keyword (EN / BM / 中文 — "I want a human" / "stop") stamps `whatsapp_conversations.ai_handoff_at` → AI **pauses for that thread** until an admin resumes it (a human can always reply; also triggerable manually from the inbox header — Pause AI / Resume AI) |
| **Outside active hours** | per-profile `auto_reply_start/end` + `ai.auto.timezone` | outside the window → **draft** (no 24/7 instant auto-replies) |
| **Long bot-only thread** | `ai.auto.max_consecutive` (default 6) | after N consecutive AI replies with no human → **draft** (auto hand-off to a human) |
| **Daily cap** | `ai.auto.daily_cap` (0 = off) | once the channel hits N auto-sends today → **draft** |
| **Cloud quality RED** | `ai.auto.pause_on_quality_red` (default on) | a Cloud channel Meta flags RED → **draft** (see *Account health* below) |
| **Cooldown** | `ai.auto.cooldown_seconds` (0 = off) | within N s of the last auto-send to this conversation → **skip** (runaway backstop) |

Each downgrade/skip is recorded on the trigger message's `meta.ai` (`{skipped: contact_blocked\|handed_off\|cooldown}` or `{mode: draft, auto_downgraded: quality_red\|outside_hours\|handoff\|daily_cap}`) for audit.

> **No "first contact" gate.** There is intentionally **no draft-on-first-contact** guard — the AI **auto-replies to a brand-new customer** too (a customer who messaged first expects a fast answer). The Cloud API is still protected by Meta's **approved-template** requirement for anything outside the 24h window; the **Bridge** (Baileys) risk of messaging a new number is surfaced in the UI, not blocked by the engine. On the **Sandbox** channel every downgrade is skipped so testing always shows the real auto-reply.

**Account health (Cloud).** `whatsapp_channels` now carries `quality_rating` (GREEN/YELLOW/RED/UNKNOWN) + `messaging_tier`, synced from the Graph API by **`whatsapp:sync-quality`** (`CloudApiDriver::fetchPhoneInfo` → `ChannelInfoSync` → `WhatsappRepository::updateChannelInfo`; run it on a schedule — the same call also syncs the number's phone / verified business name, see the inbox readMe → *Channel management*). `WhatsappChannel::isQualityRed()` drives the quality-RED guard.

**Assignment semantics:** a profile selects its channels (the AI Profile edit page has a channel multi-select). A channel has at most one profile, so assigning a channel to a profile **moves it off any other profile** (`WhatsappAiProfileRepository::syncChannels`). Deleting a profile **hard-deletes** it + its documents and **unassigns its channels** (they revert to AI-off). Profiles are hard-deleted (no tombstone), mirroring `AiCredential`.

**Knowledge documents** are shared **`Media`** records (GCS, collection `whatsapp_ai`, ≤ `MAX_DOCUMENTS` 10/profile, **text only — TXT/MD/CSV ≤ 20 MB**, plus **Word `.docx` which is converted to plain text (`.md`) on upload** via `ExtractWordText`) attached to a profile (or the global base) via the `mediable` morph and fed to every AI request with **`AiAttachment::fromMedia()`**. PDF is intentionally rejected: a big binary PDF is slow (re-uploaded whole on every call) and not reliably readable, so the providers always get small plain text. No RAG: documents ride whole on the latest customer turn — right for "a few small reference docs" (price lists, project sheets, FAQ); the schema leaves room for retrieval later.

**`WhatsappAiConfig`** (service) is the single source of truth:
- `resolveFor(?channel, ?conversation)` → the channel's profile config, or **disabled** when the channel has no profile; **flow-aware** — when the conversation is running a flow in its AI phase, the flow's profile (+ its objective) supersedes the channel default (see [flow.md](/docs/modules_handbook/manage/messages/whatsapp/flow.md)). `GenerateAiReply` resolves it **with the conversation**, so a flow takeover always uses the flow's profile + objective, never the channel default. On a **sandbox pairing test** conversation (`test_config`) the pinned profile is applied as an **override** (`sandboxTestProfile()`) so the same profile can be tried after any flow, or standalone;
- `effectiveForProfile($profile)` → `{enabled, mode, debounce_seconds, instruction, provider, model, settings, documents}`, with the global documents + instruction layered in when `extends_global`;
- `effectiveForGlobal()` → the global base alone (for its Test box);
- `systemPrompt($effective)` → base prompt + the (merged) instruction + (for a flow) the **objective** and its `[[OBJECTIVE_MET]]` completion signal (which `GenerateAiReply` parses + strips, ending the run); a flow's per-run `variables` are appended as a "Known details about this customer" block. *(The old `[[GOTO]]` cross-flow routing token was **removed** 2026-07-13 with the rest of the cross-flow jumps — `FlowEngineTest` now asserts the prompt never contains it. See [flow.md](/docs/modules_handbook/manage/messages/whatsapp/flow.md).)*

**Prompt registry:** requests run under key **`whatsapp_assistant`** (`AiRequest::PROMPT_WHATSAPP_ASSISTANT`); the base body lives in [resources/prompts/whatsapp_assistant.md](/resources/prompts/whatsapp_assistant.md) — answer only from documents/instruction (never fabricate), match the customer's language, WhatsApp-style short replies, escalate to a human on payments/refunds/anger. All calls use the **company global key** (`AiKeyService` policy) and are logged to **AI Requests** with `meta.source` (`whatsapp_ai_reply` / `whatsapp_ai_settings_test`).

**The pages** (sidebar → *WhatsApp Automation* → **AI Setting**): `Ai/Index.vue` = a **Global base card** (Edit →) + a **profiles table** (name, extends-global, mode, channel count, doc count; "New profile" modal `ProfileFormModal` → name + extends → redirect to edit) + a banner counting channels with no profile (AI off); `Ai/Edit.vue` = the scope-aware edit page (global base or a profile) wrapping the shared **`Partials/AiProfileForm.vue`** — for a profile: name, extends toggle, behaviour, instruction, documents, **channel multi-select** (each channel flagged if currently in another profile) and a Delete action; for global: just instruction + documents. The channel **Show page's AI Setting tab** shows which profile the channel is assigned to (read-only + a Manage link), since assignment is driven from the profile side. Every form has a **live Test box** — runs a question through the effective config (docs attached) via `AiProfilesController@test`, validating the knowledge before enabling anything.

## The reply engine

```
ProcessInboundWhatsAppWebhook (after persist + broadcast)
    └─ considerAiReply(message, channel)            ← best-effort, never breaks inbound
        ├─ skip: outbound/from_me, reactions, system, templates
        ├─ skip: effective.enabled = false
        ├─ handoff keyword (handoff_keywords) → handOff(conversation) + skip
        ├─ skip: contact blocked OR thread already handed off
        └─ else engage → dispatch ->delay(debounce_seconds)           ← settle window

GenerateAiReply (extends App\Jobs\Ai\AiJob → redis-ai lane, per-provider rate
limit + circuit breaker + transient retries; overlapKey "wa-reply:{message id}")
    ├─ idempotency: trigger message meta.ai already set → skip
    ├─ HARD skip: contact blocked / thread handed off → mark handled, no reply
    ├─ AUTO guards (autoDowngradeReason / withinAutoCooldown): cooldown → skip;
    │   quality-RED / outside active-hours / handoff / daily-cap
    │   → DOWNGRADE this send to a human-reviewed draft (mode := MODE_DRAFT)
    ├─ run-time re-checks (queue lag!): 24h window still open (Cloud only —
    │   Bridge + Sandbox are free-form anytime); superseded by a newer INBOUND?
    │   (only the burst's LAST message generates, so a multi-message burst gets
    │   ONE reply covering everything); answered by a newer outbound? (human sends
    │   always count; an AI reply counts only if ITS trigger is ≥ this one — a
    │   reply built from a pre-burst snapshot never swallows unseen questions; a
    │   FLOW's own scripted drip step — meta.flow — is NOT an answer, so the
    │   drip-then-AI takeover always engages, see [flow.md](/docs/modules_handbook/manage/messages/whatsapp/flow.md))
    ├─ build: last 30 conversation messages → user/assistant turns (non-text
    │   tagged "[Image] caption…", consecutive roles coalesced — blank-line
    │   separated so burst messages stay distinct — leading assistant turns
    │   dropped), knowledge docs attached to the latest customer turn,
    │   system = base + instruction + "You are chatting with {name} ({phone})"
    ├─ aiOrFail(AiClient::chat(...))                 ← retries/breaker per AiJob
    ├─ PRE-SEND re-check: the same engagement guards run AGAIN after generation —
    │   a message that arrived mid-LLM-call, or a human reply sent meanwhile,
    │   discards the now-stale text (meta.ai.skipped = "{reason}_presend")
    ├─ read+typing: MODE_AUTO first sends a READ RECEIPT (blue ticks) for the
    │   unread inbound (markCustomerRead → driver->markRead), THEN a "typing…"
    │   indicator (driver->sendTyping) before generating — read → typing → send,
    │   like a human agent; both best-effort, cosmetic
    ├─ split: always split the reply on the [[NEXT]] delimiter the model used
    │   (≤ MAX_BUBBLES, overflow folded into the last; single topic → one bubble)
    ├─ MODE_AUTO  → deliverAuto stamps ai_replied_at (inbox flags the conversation
    │               "needs review" until an admin opens it), then:
    │               1 bubble: WhatsappRepository::createOutbound (meta.ai.generated=true)
    │               → NewWhatsAppMessage + SendWhatsAppMessage (normal pipeline)
    │             → N bubbles: mark handled, hand to DripAiReply
    └─ MODE_DRAFT → WhatsappRepository::setAiDraft (bubbles joined into one draft)
                    → WhatsAppAiDraftSuggested broadcast → composer suggestion bar

DripAiReply (default lane — no AI call; sends an already-generated multi-bubble
auto-reply one message at a time)
    ├─ barge-in: a newer customer message OR a human agent reply since the
    │   trigger → stop (don't talk over them; a customer message re-engages the
    │   AI with full context — our sent bubbles are already in its history). The
    │   flow's OWN drip steps (meta.flow) are ignored here — they always sit
    │   newer than the trigger and are not a human barge-in.
    ├─ idempotent per (trigger, bubble index) — a re-dispatch never re-sends a
    │   bubble; a failed() net resumes the chain if a worker is killed mid-drip
    ├─ send bubble[index] via createOutbound + NewWhatsAppMessage + SendWhatsAppMessage
    └─ more? → driver->sendTyping (composing during the gap) +
               re-dispatch self ->delay(gap) for the next bubble (gap scales to
               the bubble's length, clamped 2–6s)
```

**Multi-bubble replies (human pacing).** The AI always answers a multi-topic burst the way a person does — a few short messages instead of one block. The model is asked (a system-prompt rule + example, always added) to split by topic with a `[[NEXT]]` delimiter, which `GenerateAiReply::splitBubbles()` parses out (capped at `MAX_BUBBLES`, empties dropped). Smaller models often ignore the delimiter, so there is a **blank-line fallback**: if no `[[NEXT]]` is found, the reply is split on blank-line paragraphs instead (coalescing a `lead-in:` line with the list it introduces), so the answer still bubbles regardless of model compliance. One bubble → the normal single send; several → `DripAiReply` trickles them with a typing indicator and a length-scaled gap between each. It stops early if the customer sends something new **or a human agent jumps in** mid-trickle (the flow's **own** scripted drip steps — `meta.flow` — are NOT treated as a barge-in, so a multi-bubble takeover after a drip is never aborted), is **idempotent per (trigger, bubble index)** so a re-dispatch never double-sends, and has a `failed()` safety net that resumes the chain if a worker is killed mid-drip. **Typing indicator** is provider-asymmetric by capability: the Cloud API only exposes it via a read-receipt on an inbound message, so it shows once before the first bubble; the Bridge (`sock.sendPresenceUpdate('composing')`) can show it before every bubble. **Draft mode ignores bubbling** — the suggestion is joined back into one editable draft for the agent.

**Burst handling (why the settle window exists):** customers split one thought across several rapid messages. Without the debounce, the FIRST message's job snapshots the history before the rest arrive, replies to topic 1 only, and the later messages' jobs then skip as "already answered" — their questions are silently swallowed (or, with several queue workers, the customer gets multiple overlapping replies). The fix is three-layered: the dispatch delay lets the burst land first; the **newer-inbound supersede** ensures only the last message's job generates (it sees the whole burst); the **pre-send re-check** catches anything that arrived during the model call. `setAiDraft` carries a matching last-writer guard so an out-of-order draft never overwrites a newer suggestion. Known trade-off: a message arriving DURING generation discards that call's tokens (correctness over cost — the superseding message's own job regenerates with full context).

**Draft delivery:** the conversation's CURRENT draft lives in `whatsapp_conversations.ai_draft` (json `{text, generated_at, trigger_message, provider, model}` — one per conversation, newest wins). It reaches the composer via the `ai_draft` prop (survives refresh) and live via the **`WhatsAppAiDraftSuggested`** broadcast. **Use** fills the textarea (agent can edit before sending), and both Use and dismiss clear it server-side (`DELETE conversations/{id}/ai-draft` → `clearAiDraft`).

**Idempotency & audit:** the trigger message's `meta.ai` records the outcome — `{mode: auto, reply_message}` / `{mode: draft}` / `{skipped: window_closed | superseded | already_answered | empty_response (+ "_presend" when caught after generation)}` — and doubles as the re-run guard. AI-sent replies carry `meta.ai.generated = true` (+ trigger uuid, provider, model) for traceability. Honest caveat: marking happens AFTER a successful send (at-least-once semantics — a crash in the tiny window between send and mark could re-send on retry; the `WithoutOverlapping` lock prevents concurrent duplicates). An auto-reply also stamps the conversation's **`ai_replied_at`** (so the inbox flags it **needs review** until an admin opens it) and, before composing, sends the customer a **read receipt** for the unread inbound — read → typing → send (see the inbox doc's *Read state & receipts*).

## GUIDELINES alignment
- **§2/§3** — all writes via repositories in `DB::transaction` (`WhatsappAiProfileRepository` upsertGlobal/create/update/delete/syncChannels; `WhatsappRepository` setAiDraft/clearAiDraft/markAiHandled/updateChannel/**handOff**/**resumeAi**/**updateChannelQuality**); thin controllers with explicit input mapping; validation in Form Requests (`StoreProfileRequest` / `UpdateProfileRequest` — now incl. `auto_reply_start/end` (`date_format:H:i`, `required_with` each other) / `UpdateGlobalRequest` / `UploadDocumentRequest` / `TestRequest`).
- **§3 constants** — `MODE_*`/`MODES`, `DOCUMENT_COLLECTION`, `DEFAULT_DEBOUNCE_SECONDS`, `MAX_BUBBLES`, `HISTORY_LIMIT`, `MAX_DOCUMENTS`; no magic numbers.
- **§7** — key model (uuid + create/update blame; intentionally hard-deleted like `AiCredential`), snake_case ≤30-char columns, no schema FKs, indexed `is_global` / `ai_profile_id`; new migrations for every change — the restructure, the `ai_draft` column, `debounce_seconds`, the later **drop** of `reply_style` + the engagement columns (`...06_16_000001_drop_reply_style_and_engagement_*`), and the anti-ban additions (`...06_29_000002_add_auto_reply_hours_to_whatsapp_ai_profiles`, `...06_29_000003_add_quality_tracking_to_whatsapp_channels`, `...06_29_000005_add_ai_handoff_to_whatsapp_conversations`) — never edited a committed one.
- **AI-module rules** — background AI extends `AiJob` (`run()` + `aiOrFail()`, overlap key, idempotent re-run guard); prompt key registered (constant + registry + md file); company key; everything logged to `ai_requests`.

> **History note:** v1 (same day) keyed profiles by `channel_id` (one global row + per-channel overrides with field-level inheritance). The restructure migration (`...000003`) kept the former global row's instruction + documents, dropped the per-channel override rows, and flipped the relationship — channels now point at a profile (`ai_profile_id`).

## Related files
- [src/Whatsapp/WhatsappAiProfile.php](/src/Whatsapp/WhatsappAiProfile.php) (named profiles + global base; `channels()` / `documents()`) · `WhatsappChannel::aiProfile()` · migrations `...000001_create_whatsapp_ai_profiles_table` · `...000002_add_ai_draft_to_whatsapp_conversations_table` · `...000003_restructure_whatsapp_ai_profiles_table` · `...000004_add_ai_profile_id_to_whatsapp_channels_table`
- [src/Whatsapp/Repositories/WhatsappAiProfileRepository.php](/src/Whatsapp/Repositories/WhatsappAiProfileRepository.php) · [src/Whatsapp/Support/AiProfilePresenter.php](/src/Whatsapp/Support/AiProfilePresenter.php) · `WhatsappRepository` (`setAiDraft` / `clearAiDraft` / `markAiHandled`)
- [src/Whatsapp/Services/WhatsappAiConfig.php](/src/Whatsapp/Services/WhatsappAiConfig.php) — effective-config resolver + system prompt
- [app/Jobs/Whatsapp/GenerateAiReply.php](/app/Jobs/Whatsapp/GenerateAiReply.php) — the engine (split bubbles, typing, single-vs-drip) · [app/Jobs/Whatsapp/DripAiReply.php](/app/Jobs/Whatsapp/DripAiReply.php) — paced multi-bubble delivery · [app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php](/app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php) (`considerAiReply`)
- `WhatsappDriver::sendTyping()` — typing indicator, implemented by [CloudApiDriver](/src/Whatsapp/Drivers/CloudApiDriver.php) (read-receipt + typing_indicator) and [BridgeDriver](/src/Whatsapp/Drivers/BridgeDriver.php) (→ wa-bridge `POST instances/{id}/presence` → `sock.sendPresenceUpdate('composing')`)
- [app/Events/Whatsapp/WhatsAppAiDraftSuggested.php](/app/Events/Whatsapp/WhatsAppAiDraftSuggested.php) — draft broadcast
- [app/Http/Controllers/Manage/Whatsapp/AiProfilesController.php](/app/Http/Controllers/Manage/Whatsapp/AiProfilesController.php) · `InboxController@dismissAiDraft` · [app/Http/Requests/Manage/Whatsapp/Ai/](/app/Http/Requests/Manage/Whatsapp/Ai/)
- [resources/js/Pages/Manage/Messages/Ai/Index.vue](/resources/js/Pages/Manage/Messages/Ai/Index.vue) · [Ai/Edit.vue](/resources/js/Pages/Manage/Messages/Ai/Edit.vue) · [Partials/AiProfileForm.vue](/resources/js/Pages/Manage/Messages/Partials/AiProfileForm.vue) · `Inbox.vue` (draft suggestion bar + `.WhatsAppAiDraftSuggested` listener)
- [resources/prompts/whatsapp_assistant.md](/resources/prompts/whatsapp_assistant.md) (now discloses it is automated) · [config/ai_prompts.php](/config/ai_prompts.php) (`whatsapp_assistant`)
- **Anti-ban guards:** [config/whatsapp.php](/config/whatsapp.php) (`ai.auto.*` + `ai.opt_out_keywords`) · `WhatsappChannel` (`quality_rating` / `messaging_tier` / `isQualityRed()`) · `WhatsappContact` (`ai_opt_out_at` / `hasOptedOutOfAi()`) · `WhatsappAiProfile::autoReplyActiveAt()` · [src/Whatsapp/Drivers/CloudApiDriver.php](/src/Whatsapp/Drivers/CloudApiDriver.php) `fetchPhoneInfo()` · [src/Whatsapp/Services/ChannelInfoSync.php](/src/Whatsapp/Services/ChannelInfoSync.php) · [app/Console/Commands/SyncWhatsappQuality.php](/app/Console/Commands/SyncWhatsappQuality.php) (`whatsapp:sync-quality`)
- Routes: `manage.messages.ai.*` + `manage.messages.conversations.ai-draft.dismiss` ([routes/web.php](/routes/web.php))

**Prerequisites:** an AI provider key on the **global** scope (Manage → AI Requests → Providers), Horizon running (the engine rides the `redis-ai` lane → `supervisor-ai`; `php artisan horizon` does NOT hot-reload changed job code — `horizon:terminate` to reload), Reverb for live drafts. **Not yet implemented (future):** RAG over large document sets, AI replies that attach media, per-conversation AI on/off toggle, customer-media (image/voice) fed to the model.

**Testing a profile end-to-end (no real number):** beyond the per-form **Test box** (one-shot Q&A against the effective config), drive the FULL reply engine on a **Sandbox** channel — the inbox "send as customer" bar, or `php artisan db:seed --class='\WhatsappDemoSeeder'` then `php artisan whatsapp:flow-test "hello"` (inject an inbound, watch the AI reply, diagnose any skip/draft). Run it under **WSL** (see [flow.md](/docs/modules_handbook/manage/messages/whatsapp/flow.md) → *Testing without a real WhatsApp number*).
