# AI Voice Agent (Shared · `Src\Common\VoiceAgent` + `Src\VoiceAgent`)

**Context:** shared library + two Manage pages · **Nav:** Phone Call → **AI Calls**
(`manage.calls.ai-calls.index` — one row per real-time AI conversation call, transcript in the
expand body; nothing about a call is editable, but **Block / Unblock number** are row actions
behind `MANAGE_CALLS`) and **AI Profiles** (`manage.calls.ai-profiles.index` + its Show page —
the campaigns; see
[ai-profiles.md](/docs/modules_handbook/shared/voice-agent/ai-profiles.md)) ·
**Routes:** `webhooks.retell.voice`, `manage.calls.ai-calls.*`, `manage.calls.ai-profiles.*`,
`ai-callback/{token}` · **Used by:** the funnel welcome call, a
[payment item's automation](/docs/modules_handbook/manage/payments/payment-automation/readMe.md),
the `voice-agent:*` console commands, and — as a per-agency FORK on its own Retell account —
the AI Appointment System suite · **Provider wiring:**
[retell-setup.md](/docs/modules_handbook/shared/voice-agent/retell-setup.md).

⚠️ The AI Calls list, its status counts and its total are all scoped through `LeadVisibility`.
A call the linker never matched to a lead (`lead_id IS NULL`) belongs to nobody's scope — it
is visible at `LEVEL_ALL` and hidden otherwise, deliberately.

## What it does

Places **outbound real-time AI conversation calls** — the agent listens and talks back —
through **Retell AI**, and keeps the CRM's own durable record of every call (outcome,
duration, cost, full transcript, post-call analysis) in `ai_voice_calls` +
`ai_voice_call_turns`.

This is a **sibling of the Voice module, not an extension of it**:

| | [`VoiceCaller`](/docs/modules_handbook/shared/voice/readMe.md) (Twilio) | `ConversationalCaller` (Retell) |
|---|---|---|
| Audio | One-way playback of a pre-rendered WAV (TwiML `<Play>`) | Bidirectional — STT → LLM → TTS in real time |
| Use | Funnel voice automations (reminders) | Conversations (follow-up, qualification, 谈天) |
| Cost | Twilio minutes only | Retell per-minute (infra + TTS + LLM) **and** Twilio minutes |

`VoiceCaller`'s contract has no return-audio path and cannot be bent into a conversation —
which is why this is a second interface, bound separately.

**Disclosure is structural, not optional.** Retell's ToS (§5(a)/(d)) requires every outbound
call to open by naming the business and prohibits misleading anyone about the agent being an
AI. The approved opening line ("您好，我是 PropertyLab 的 AI 助理，打来跟进您之前看的项目")
lives in the agent's prompt **in the Retell dashboard** — changing it away from an
AI-disclosing opener is a ToS breach that risks the account, independent of Malaysian law.
The voice is a clone of the owner's own voice, created by **him personally** in his own
ElevenLabs/Retell account (voice-captcha enforced) with his written consent on file — a
voiceprint is *sensitive personal data* under the PDPA Amendment Act 2024.

## How it works

- **`Src\Common\VoiceAgent\ConversationalCaller`** — the interface: `isConfigured()`,
  `call($to, $dynamicVariables, $metadata, $overrideAgentId): ?string` (returns the provider
  call id, never throws — the 4th argument is the PROFILE's own Retell agent, which is how a
  campaign speaks with its own prompt; null falls back to `services.retell.agent_id`), `getCall($providerCallId): ?array` (reconciliation). Bound in
  `AppServiceProvider` to **`RetellConversationalCaller`** (thin HTTP, two endpoints, no
  SDK) — or **`LogConversationalCaller`** on local unless `VOICE_REAL_IN_LOCAL=true`, the
  same switch the Voice module uses, so a dev checkout can never place a billed call.
- **Credentials are DB-first** — the **AI voice agent — Retell** card on **Messages →
  Settings → Delivery APIs** (`messaging_credentials`, `TYPE_VOICE`, provider `retell`;
  fields: API key (secret), Agent ID (`agent_…`, format-validated at the form), Caller ID
  number (E.164)). `MessagingCredentialProvider` pushes saved values into
  `services.retell.*` at boot; `.env` (`RETELL_API_KEY` / `RETELL_AGENT_ID` /
  `RETELL_FROM_NUMBER`) is the fallback. ⚠️ Horizon workers and the scheduler pick a
  credential change up on the next restart.
- **The call record is ours, not the provider's.** `Src\VoiceAgent\AiVoiceCall` (key model:
  uuid + blame + soft delete) + `AiVoiceCallTurn` (child rows, one per spoken turn, replaced
  wholesale on webhook retry). Everything worth keeping is folded in by the webhook because
  provider retention is not ours to rely on. `status` is OUR reading
  (Pending/In progress/Completed/No answer/Busy/Voicemail/Failed);
  `disconnection_reason` keeps Retell's verbatim verdict; `analyzed_at` marks the late
  `call_analyzed` event separately from the call lifecycle.
- **Webhook** — `POST /webhooks/retell/voice` (`RetellWebhookController`), public and
  CSRF-exempt; auth is the `x-retell-signature` header — format `v={ms-timestamp},d={hex}`, digest =
  HMAC-SHA256(**raw body . timestamp**) with the webhook-badged API key, ±5-min freshness
  window (from retell-sdk's webhook_auth; the docs only say "use the SDK" — a bare
  HMAC-of-body guess 401s every event silently) — verified by
  `RetellWebhookSignature` before anything is read. Events: `call_started` →
  In progress; `call_ended` → terminal outcome + transcript turns; `call_analyzed` →
  summary/sentiment/verdict. Unknown events and unknown call ids are acknowledged (204) so
  Retell stops retrying. Payload traps the handlers respect: unset keys are **omitted, not
  null**, and a call that never connects fires **no events at all**. The controller only folds
  payloads into the row — every CONSEQUENCE of an outcome lives in `AiCallAftermath` (below).
  **One canonical receiver (2026-08-23):** the agents are shared across environments but Retell
  takes a SINGLE `webhook_url` per agent, so every box that syncs must agree on one receiver —
  **production** (`app.propertylab.com.my`). A non-production box pins `RETELL_WEBHOOK_URL` to
  production's URL in `.env`; `RetellAgentSync::webhookUrl()` uses it on BOTH create and update
  (update always re-sends it, so a re-sync corrects a stale receiver — before this, webhook_url
  was written at create only and agents kept pointing at whichever box first created them, which
  is exactly how a whole morning of production calls webhooked into the dev box as "unknown
  call"). For a second environment to still see events in real time, the receiver sets
  `RETELL_WEBHOOK_FORWARD_URL` and the controller relays every verified event via
  `ForwardRetellWebhook` (raw body + original signature — the shared API key signs both sides;
  `X-Peta-Forwarded` marks the copy so it is never re-forwarded; retries stay inside the
  signature's ±5-min freshness window). Without the relay, the non-receiving box still settles
  its own calls through the reconcile sweep, ≤10 min late. The Twilio ONE-WAY voice module needs
  none of this: its `Url`/`StatusCallback` are signed URLs minted per call, so each box's calls
  webhook back to itself by construction.
- **`AiCallAftermath`** (`Src\VoiceAgent\Services`) — the ONE owner of what happens because a
  lead call reached its outcome: the 🤖 Discussion comment on the lead (COMPLETED + analyzed),
  the thanks/missed WhatsApp dispatch, the flagged-number Telegram alert. Exists because it
  originally lived inside the webhook controller only — and on 2026-08-23 a whole morning of
  funnel calls settled by the **reconcile sweep** (their webhooks hit a box that didn't hold the
  rows) got no comments and no follow-ups. Webhook, reconcile and the backfill command all call
  the same two arms: `settled()` (missed nudge — `AiVoiceCall::qualifiesForMissedFollowUp()`:
  NO_ANSWER/BUSY/VOICEMAIL, plus FAILED only when it was a GENUINE attempt — provider_call_id
  present and `disconnection_reason` not in `REFUSAL_REASONS`; our own refusal rows must never
  tell a person "we called you") and `analyzed()` (comment + thanks + flags). Idempotency is
  meta-stamped (`lead_comment_at`, `followup_sent`) — NOT inferred from analyzed_at, which only
  the webhook path can observe transitioning. Every arm is fail-soft.
  ⚠️ **A call placed FOR A PAYMENT ITEM takes that item's own rules instead of the funnel's
  templates.** Both arms branch on `AiVoiceCall::isPaymentCall()` (set from
  `meta.payment_link_id` / `meta.purchase_history_id`, stamped at placement) and hand off to
  `App\Actions\Payment\DispatchPaymentAutomationAction::callMissed()` / `::afterCall()`.
  The funnel's `ai_caller_thanks_v2` / `ai_caller_missed_v2` are worded for a webinar
  REGISTRATION and would tell a buyer we called about something they never signed up for.
  The Discussion comment, the no-lead-turns guard and the flagged-number alert apply to both
  alike — see
  [Payment Items · Automation](/docs/modules_handbook/manage/payments/payment-automation/readMe.md).
- **`RetellCallMapper`** (`Src\VoiceAgent\Support`) translates Retell's payload into model
  attributes + turn rows — ONE mapper shared by the webhook and the reconcile pass, so both
  settle rows identically. Cost note: Retell reports `combined_cost` in US **cents**; the
  mapper converts to USD and the repository keeps the raw block in `meta.call_cost` for audit.
- **Reconcile** — `voice-agent:reconcile` (scheduled every 10 min, gated on the API key
  being configured): sweeps unsettled rows older than 10 min and asks Retell directly;
  never-placed rows close as Failed(`never_placed`), and anything still unsettled after 6 hours closes as Failed(`unsettled_timeout`) so a lost call can never starve the sweep window. This is the safety net for the
  no-webhook-on-no-connect gap and for retry-window misses. A reconciled settle runs the SAME
  `AiCallAftermath` as a webhook one (the sweep writes outcome + analysis in one settle, so both
  arms run there). `voice-agent:followup-doctor {--send=<lead id>}` answers "why did the WhatsApp not go out" in
one command — it checks Horizon, the `default` queue depth, the CONNECTED Cloud channel, both
templates' local status and failed follow-up jobs, in the order the job hits them, and
`--send` performs one REAL synchronous send so the driver's own error surfaces instead of
dying inside a worker. The first check is first for a reason: the lead comment is written
**synchronously** in the webhook while the WhatsApp is **queued**, so "the note appeared but no
message arrived" is a worker problem, never a template one.
`voice-agent:backfill-followups {--days=3} {--dry-run}` replays the aftermath
  over already-settled rows that never got it (data migrations, pre-hook history) with per-lead
  dedupe the live paths never need: latest completed call per lead gets the thanks, latest miss
  gets the nudge, a lead who answered anything in the window gets no missed nudge, and every
  skipped row is stamped `superseded` so no later sweep reconsiders it.
- **Cloning a voice happens IN the CRM, with the script on screen (2026-08-26)** — the clone
  panel on the profile form has two sources: **Record here** (mic picker + live level meter +
  MediaRecorder + playback before committing) and **Upload a file**. The read-aloud script lives
  in [`resources/js/utils/voiceCloneScript.js`](/resources/js/utils/voiceCloneScript.js) and is
  shown WHILE recording, because the sample IS the tone: cloning copies the pitch variation,
  breath pacing and emotional weight it finds, so a flat 30-second read produces a flat agent on
  every call it will ever make. Its eight sections are the beats an outbound call actually needs
  (greet / state facts / say money out loud / ask / react / soften / clarify / close), in the
  Mandarin-plus-English-terms mix the agents speak — an all-Mandarin sample makes the English
  property terms come out in the wrong voice. The UI warns under 90s and blocks under the
  provider's 10s floor, and stops itself at the 300s ceiling rather than losing a long take.
  **Server-side normalisation** (`AiProfilesController::normalizeSample`) re-encodes every
  sample to mono 44.1kHz MP3 with ffmpeg before it reaches the provider — the browser records
  webm/opus (mp4 on Safari), which MiniMax will not take, and a hand-picked file can be a stereo
  96kHz WAV. Without ffmpeg on the host it passes a normal upload through untouched but returns
  an actionable error for a browser recording rather than letting a confusing provider error
  surface.
- **Tone is per-campaign too (2026-08-26)** — `voice_temperature_pct`, `voice_volume_pct`,
  `voice_emotion` and `voice_model` on the profile (all nullable = provider default, so every
  existing profile sounds exactly as it did). `RetellAgentSync::voiceFields()` sends them on
  create and update, percent columns converting to the provider's 0.0–2.0 floats. Two provider
  gates worth knowing: **`voice_emotion` is honoured only for Cartesia and MiniMax voices** —
  this account's clones are MiniMax, so it works here, but it would be silently ignored on an
  ElevenLabs voice; and **`voice_model` must match the voice's own provider** (a MiniMax clone
  belongs on a `speech-*` engine), a mismatch being accepted by the API and only heard on the
  call. `enable_expressive_mode` is deliberately not offered: the API accepts it, but the docs
  restrict it to platform voices, so on a custom clone it is a switch that does nothing.
- **Each campaign picks its LANGUAGE (2026-08-31)** — `ai_call_profiles.language` (nullable
  = inherit the template agent's array). **ONE language per agent, never a list**: Retell's
  own docs warn that an agent carrying Cantonese alongside Mandarin or English "will mix them
  up frequently no matter which voice or ASR provider you pick", so a bilingual market is two
  profiles chosen per contact, not one profile with two languages. `AiCallProfile::LANGUAGES`
  is the whitelist the Form Request validates against — Retell rejects an unknown code and
  fails the whole sync. **`yue-CN` is the ONLY Cantonese code it accepts** (`yue` and `zh-HK`
  are both rejected — verified against the live API, not the docs).
  `RetellAgentSync::agentLanguage()` sends it on create and re-sends on update.
- **The guard prompt is language-aware.** `composedPrompt()` looks for
  `voice_agent_guard_{language}` before the shared `voice_agent_guard` body, because the
  guard is appended VERBATIM to the agent's prompt — the Simplified-Chinese Malaysian one
  would put Mandarin phrasing in the mouth of a Cantonese agent that has just been told never
  to speak Mandarin. Ships with `voice_agent_guard_yue-CN`; both are admin-editable on the AI
  Prompts page, so the repo file is the default, not the live text.
- **Profiles are multi-tenant (`ai_call_profiles.group_id`).** A NULL `group_id` is an
  install-wide CRM campaign (the pages above). A non-null one belongs to an **AI Appointment
  System** agency, served by its own surface at `Manage\AppointmentEngine\AiProfilesController`
  (`manage.appointment-engine.calls.ai-profiles.*`) against that agency's OWN Retell account
  and its OWN webhook receiver (`webhooks.ae.retell`). Treat the Appointment Engine as a
  second FORK of this module, not a second consumer of the shared account — see
  [retell-setup.md](/docs/modules_handbook/shared/voice-agent/retell-setup.md).
- **Each campaign picks its own brain (2026-08-26)** — `ai_call_profiles.llm_model` (nullable;
  null inherits the tuned template agent's model). Until now a 20-second "your webinar is
  confirmed" call and a ten-minute investment pitch that must hold a nine-step branching script
  and keep six figures straight ran on the same cheap fast model — different jobs: one wants
  latency, the other wants reasoning. Options live in `AiCallProfile::LLM_MODELS` (the whitelist
  the Form Request validates against — an invalid name is accepted by Retell's PATCH and then
  fails the sync), surfaced as the **Brain** select on the profile form. `RetellAgentSync` sends
  it on create and re-sends it on update, so switching a campaign's model reaches the provider on
  the next Sync while a profile that never picked one keeps what it was created with. It is a
  **trade-off, not an upgrade**: the heavier models think longer, and on a phone call that pause
  is dead air the customer hears — `services.retell.model_high_priority` (default true) buys
  dedicated capacity to soften it.
  ⚠️ **`normalize_for_speech` is deliberately NOT sent** — Retell answers the PATCH with a 200
  and silently drops it (absent from every get-agent response, verified 2026-08-26). Speech
  shaping belongs in the prompt instead ("this is spoken out loud — never say symbols"), which
  is also what stops a model reciting a markdown-formatted script from reading the asterisks out.
  ⚠️ The update LLM payload must **never** be wrapped in `array_filter`: a null `begin_message`
  and an empty `knowledge_base_ids` are deliberate instructions ("clear it", "detach the KB"),
  and filtering them turns a removal into a silent no-op.
- **Opening-line guards (2026-08-26)** — the person who ANSWERS a phone speaks first: "Hello?"
  is universal phone etiquette, not an interruption. At the provider defaults
  (`begin_message_delay_ms` 0, `interruption_sensitivity` 1) the agent started talking into
  that greeting and any single "halo?" aborted the sentence mid-word — and the sentence being
  aborted was the OPENING, which carries the AI disclosure Retell's ToS requires. Three layers,
  all in `guardFields()` + the shared guard prompt so every campaign gets them:
  `begin_message_delay_ms` 1000 (the greeting gets its own turn — this is the root-cause fix),
  `interruption_sensitivity` 0.3 (a short greeting or an "嗯" no longer cuts the agent, a real
  sentence still does), and a guard-prompt rule telling the agent to answer a greeting briefly
  and then FINISH the three disclosure beats before moving on. ⚠️ Retell has **no** per-message
  uninterruptible flag — `interruption_sensitivity` is global for the whole call, so **never set
  it to 0**: a customer saying "不方便" would be talked over, which is worse than the bug.
  Tunable via `RETELL_BEGIN_DELAY_MS` / `RETELL_INTERRUPTION_SENSITIVITY`.
- **⚠️ Quiet hours ship SWITCHED OFF.** `services.retell.quiet_hours_enabled`
  (`RETELL_QUIET_HOURS`) defaults to **false** by the owner's decision: a registration is
  dialled ~20 seconds later whatever the hour, because the lead has just typed their number
  and the phone is in their hand. `PlaceAiVoiceCall::outsideCallWindow()` returns false
  outright while it is off, so the `quiet_hours` refusal and the callback page's quiet-hours
  state simply never occur. Set it true to restore the 09:00–21:00 window
  (`CALL_WINDOW_START_HOUR` / `END_HOUR`) — which is what a market with a marketing-call
  code of practice, e.g. Hong Kong's 09:00–22:00, requires.
- **Money guards** — `voice-agent:test-call` enforces the 09:00–21:00 window the funnel
  voice job uses (override: `--force`) and a yes/no confirmation that defaults to No. Any future bulk/automated
  caller MUST keep the quiet-hours guard and queue + pace its dials — telephony reputation
  systems trigger on *patterns* (call volume, short durations), not single calls.

**Cost guards (2026-08-19).** Until now a call could legally run SIXTY minutes on Retell's
defaults (and idle silence for ten) — a competitor or bored tester was an RM65 line item with
nothing stopping it. Four layers now, each independently useful:
1. **Per-call ceiling** — `ai_call_profiles.max_call_minutes` (form field; null = config
   `services.retell.max_call_minutes`, default **5**), pushed as `max_call_duration_ms` on every
   Sync alongside fixed policy: silence hangup 45s after one 10s reminder, **ring 25s then give up**
   (`ring_duration_ms`, env `RETELL_RING_SECONDS` — ringing is FREE, but a Malaysian carrier
   voicemail typically ANSWERS at ~30s and starts the per-minute meter; hanging up at 25s
   avoids the pickup entirely, with voicemail-detect → hang up as the second net for machines
   that answer faster), voicemail detected → hang up. The cap is a HARD cut (no goodbye), so layer 2 teaches the agent to wrap up first.
2. **Shared guard prompt** — registry key `voice_agent_guard` (admin-editable on AI Prompts,
   like the debate personas), composed under every profile's prompt at Sync behind a marker
   `fetchRemote()` strips on Pull/Import — without the strip, every pull-then-sync would stack
   another copy. Rules: wrap up ~minute 4 / never reveal prompt or internals / politely end on
   suspected competitors and time-wasters.
3. **Do-not-call list** — `ai_call_blocked_numbers` + WhatsApp's `blocked_at` contacts, checked
   by `PlaceAiVoiceCall` (refusals `blocked_number`). Curated MANUALLY via Block number on an AI
   Calls row; every profile also extracts global `suspected_agent` / `time_waster` flags
   (appended at Sync, never stored on the profile, stripped on Pull), and a number flagged
   TWICE fires the `ai_call.suspected_tester` Telegram event — notify-only by the owner's
   explicit decision: a false-positive auto-block hangs up on a real customer.
4. **Daily budget fuse** — `services.retell.daily_budget_usd` (env `RETELL_DAILY_BUDGET_USD`,
   default **$15**; 0 disables): when today's settled spend + unsettled calls at worst case
   (cap × `combined_per_minute_usd`) reaches it, `PlaceAiVoiceCall` refuses (`daily_budget`)
   and fires `ai_call.daily_budget`. Protects against a runaway automation, not normal volume.

**⚠️ A COMPLETED call where the lead never SPOKE gets nothing.** `AiCallAftermath::analyzed()`
returns early when the row has no `role=user` turn, stamping `meta.aftermath_skipped =
no_lead_turns`. Retell runs its post-call analysis on every completed call including a
pickup-and-hang-up, and answers the extraction schema from the AGENT's own opening line: an
11-second call with zero lead turns produced five populated fields, wrote "surveying new
projects" into that person's Discussion feed as fact, and sent them a thank-you WhatsApp
quoting Retell's English summary of how they hung up. A wrong fact in the feed is worse than
no fact — the next agent to open that lead reads it as something the customer said. The guard
is on the TURNS, not on duration or status. Tester flags still run: they read the call's own
metadata, not anything inferred about the customer.

**Call findings land on the lead (2026-08-19).** When `call_analyzed` settles a LEAD call
(never a browser test), the webhook drops a **general Discussion comment** on the lead —
🤖-prefixed, system-authored (`created_by` null → renders "Unknown"), carrying the AI summary
plus every extracted field the call actually learnt (`unknown` and empties are skipped; `none`
stays — "owns no property yet" is an answer, not a gap; the ⚠️ tester flags only when true).
Guarded by the row's **`meta.lead_comment_at`** stamp — NOT by `analyzed_at`, which only the
webhook path can observe transitioning (the reconcile sweep writes outcome and analysis in one
settle). Retell retries webhooks up to 3×, the analysis write
is idempotent but a comment is not, so side effects fire only on the FIRST analyzed event.
Fail-soft — a Discussion problem must never 4xx the webhook into retrying.

**The WhatsApp loop (Phase 2, 2026-08-23).** After the welcome call settles, the funnel talks
back on WhatsApp via the two approved Cloud templates:
- Answered + analyzed → `ai_caller_thanks_v2` (UTILITY) carrying the lead's own extracted words
  ({{2}} composed by `AiCallAftermath::composeThoughts` from `webinar_topics` / `current_problem` / `area_of_interest`, one line, ≤500 chars —
  template params reject newlines), with quick replies whose payloads the inbound pipeline owns:
  `AI_CALL_ACK` (friendly ack) and `AI_CALL_AGAIN:{lead_uuid}:{profile_slug}` → a REPEAT call
  through `PlaceAiVoiceCall` with `is_repeat_call=yes` + `previous_summary`, which both funnel
  prompts handle ("不重问，接着聊").
  ⚠️ **The slug is carried in the payload because the tap arrives with NO other context
  (2026-08-26).** It used to be guessed from the lead's call history and fell back to a
  hardcoded `'bootcamp-welcome'` — a slug no profile has had since the campaign was renamed, so
  every tap by a lead without a COMPLETED-with-summary call placed a `profile_not_found` FAILED
  call and answered "这通电话暂时拨不出去" (confirmed on a real tap, 2026-08-23 01:51).
  `considerAiCallButton::callbackProfile()` now resolves most-specific-first — payload slug →
  the campaign that last called this lead (ANY outcome) → the single callable campaign when
  there is exactly one — and returns **null** rather than guessing, because calling with the
  wrong campaign's script is worse than not calling. Old buttons already delivered carry the
  two-part payload and still resolve through the history fallback. Never reintroduce a literal
  slug fallback. Handled in `ProcessInboundWhatsAppWebhook::considerAiCallButton`
  BEFORE CTA/flow/AI — a button tap must never be answered by the default AI as if it were a
  question — and replies ride the normal outbound pipeline (24h window opened by the tap itself).
- Rang unanswered (`qualifiesForMissedFollowUp()` — NO_ANSWER/BUSY/VOICEMAIL plus genuinely-dialed
  FAILED rows like a carrier geo-permission refusal on a foreign number; NEVER our own refusal
  rows, which are listed in `AiVoiceCall::REFUSAL_REASONS`)
  → `ai_caller_missed_v2` (Meta re-categorised it MARKETING) with the `/ai-callback/{token}` URL
  button. The opted-out-at-registration case sends the same template 2 minutes after sign-up —
  they said "don't call", so the ASK moves to WhatsApp and the tap is the consent.
`SendAiCallFollowUp` stamps `meta.followup_sent` on the call (webhook retries re-dispatch; one
message per call) and caps missed-nudges at one per lead per day (the callback button can itself
go unanswered — without the cap that loop messages the person after every missed attempt). A
cap-skipped row is stamped `capped`, not left bare — an unstamped row would qualify again on any
later re-dispatch and message the person about a days-old call.

**The send is a real `whatsapp_messages` row (2026-08-24).** The job persists contact →
conversation → outbound TEMPLATE row BEFORE calling the driver and records the returned wamid
after (`recordSendResult`) — the OTP send's exact pattern — so Meta's delivered/read receipts
advance the row's ladder and the message appears in the lead's inbox thread. The call keeps
`meta.followup_message_id`; sends from before this have no linked row and render as "sent, no
tracking". **`Src\VoiceAgent\Support\AiCallPresenter`** is THE shared row shape (call fields +
transcript + `followup` block with kind/label/color + delivery status) consumed by all three
surfaces: the **AI Calls page** (WhatsApp column), a lead's **Channel → AI Caller** tab
(`useLeadTabs` `ai-caller`, panel `Partials/Tabs/Channel/AiCallerTab.vue`, payload key
`aiVoiceCalls` in BOTH `LeadsController::show()` and `::quick()`, gated `VIEW_CALLS`), and a
funnel session's **Registrations roster** (`EventsController::aiCallsForLeads()` — latest call
per lead in one grouped query, AI CALL band). Delivery ONLY advances in the environment
receiving Meta's status webhooks (production) — elsewhere a send honestly stays "Sent".

**Welcome delay is second-granular now:** `funnel_automation_messages.offset_seconds` (10–3600,
AI-call welcome only; null = legacy `offset_minutes`) — `welcomeDelaySeconds()` feeds both the
queue delay and the thank-you countdown, and the rule modal's delay picker gained a "seconds"
unit. Both live funnels run 20s: the phone rings while the thank-you page is still open.

⚠️ **The chat agent runs its OWN mirror llm — never share the voice llm (2026-08-23).** Retell
couples a chat agent's `response_engine.version` to the chat agent's own version counter
("Response engine version must match agent version"), and `update-chat-agent` DRAFTS its llm
from the CHAT side's base. When chat and voice shared one llm, two disasters followed: the Test
LLM silently served the creation-day prompt forever (found when it kept quoting a hardcoded
showtime the voice prompt had lost ten versions earlier), and the attempted fix — publishing the
chat agent to catch up — minted llm versions from the stale base ONTO THE SHARED LINEAGE,
regressing the LIVE voice agents to the antique prompt. `ensureChatAgent` now keeps a dedicated
`retell_chat_llm_id` per profile and syncs it with the same composed content each time (draft
chat agent → overwrite its llm draft → publish); `deleteArtifacts` removes it. One llm, one
agent lineage — always.

**Session date/time is a VARIABLE, never prompt text.** The funnel welcome passes `event_date` /
`event_time` (from `FunnelWhatsappComposer`); the profiles carry a「直播是几时」section that
allows ONLY those tokens, with an explicit fallback ("在你报名的 link 里") when empty (web
tests, callback taps). Hardcoded showtimes kept leaking back through TEACHING EXAMPLES — the
哈哈 lesson again: an example containing「晚上八点到十点」became the stated showtime. Format
guidance now describes the shape (「几月几号、晚上几点」) without a speakable time.

## Reference usage

**The modular entry point — this is how EVERY feature places an AI call.** Name a
profile (Phone Call → AI Profiles) by slug, give a phone; guards (09:00–21:00
window, profile callable, phone valid) are enforced inside and refusals come back
as auditable FAILED rows — never exceptions:

```php
use App\Actions\PlaceAiVoiceCall;

$call = app(PlaceAiVoiceCall::class)->run(
    'cochrane',                        // profile slug — the campaign to run
    $lead->user?->profile?->phone,     // any format; normalised inside
    ['lead_name' => $name],            // dynamic variables for the prompt
    $lead->id                          // links the call to the lead
);
// $call is the ai_voice_calls row; webhooks settle it from here.
```

A profile must be **synced** (its edits pushed to Retell via the page's Sync
button) before it is callable; `PlaceAiVoiceCall` refuses `profile_not_callable`
otherwise.

**First consumer:** the funnel Automation tab's **AI call** medium
(`FunnelAutomationMessage::MEDIUM_AI_CALL` → `SendFunnelAiCall`) — "call X
minutes after registering" and session-timed rules, per-rule profile, quiet
hours deferred to the next 09:00. See the
[funnel-automation handbook](/docs/modules_handbook/manage/events/funnel-automation/readMe.md). The lower-level `ConversationalCaller` + `AiVoiceCallRepository` pair
stays available for surfaces that need custom row handling (the test command is
the reference), but new integrations should not need it.

**Second consumer (2026-09-01): a PAYMENT ITEM's "Call now"** — [`App\Actions\Payment\PlacePaymentAiCall`](/app/Actions/Payment/PlacePaymentAiCall.php)
dials a buyer with the item's brain (`payment_links.ai_call_profile_id`) and passes
`run()`'s new sixth argument, **`$meta`** (`['payment_link_id' => …, 'purchase_history_id' => …]`),
which is written on the ledger row AT CREATION — the one write, so nothing outside the action
can drift. That meta is what makes `AiVoiceCall::isPaymentCall()` true, and **`AiCallAftermath`
branches on it**: a payment call gets the item's own after-call / no-answer rules
(`DispatchPaymentAutomationAction::afterCall()` / `callMissed()`, ledger-claimed per call) instead
of `ai_caller_thanks_v2` / `ai_caller_missed_v2`, whose wording is bound to a webinar registration.
The Discussion comment and the flagged-number alert apply to both. The call-me-again token carries
the purchase too, so `AiCallbackController` re-dials through the same action. A feature that needs
its own aftermath follows this shape: stamp context via `$meta`, branch in `AiCallAftermath`, never
a second webhook listener. See [Payment Items · Automation](/docs/modules_handbook/manage/payments/payment-automation/readMe.md).

Console: `php artisan voice-agent:test-call 0108685352 --var=lead_name=Shawn` (real, billed;
follows the row live and prints the transcript). `php artisan voice:test-call …` first when
diagnosing — it isolates the bare Twilio leg.

## Related files

**The shared contract (`Src\Common\VoiceAgent`)**
- [ConversationalCaller.php](/src/Common/VoiceAgent/ConversationalCaller.php) — the interface
  (`isConfigured()`, `call()`, `getCall()`), bound in `AppServiceProvider`
- [RetellConversationalCaller.php](/src/Common/VoiceAgent/RetellConversationalCaller.php) ·
  [LogConversationalCaller.php](/src/Common/VoiceAgent/LogConversationalCaller.php) (local, unless `VOICE_REAL_IN_LOCAL=true`)
- [RetellWebhookSignature.php](/src/Common/VoiceAgent/RetellWebhookSignature.php) — HMAC of raw body + timestamp, ±5-min window

**The records (`Src\VoiceAgent`)**
- [AiVoiceCall.php](/src/VoiceAgent/AiVoiceCall.php) — `STATUSES`, `SOURCES`, `REFUSAL_REASONS`, `FOLLOWUPS`, `qualifiesForMissedFollowUp()`, `isPaymentCall()` · [AiVoiceCallTurn.php](/src/VoiceAgent/AiVoiceCallTurn.php)
- [AiCallProfile.php](/src/VoiceAgent/AiCallProfile.php) — `LANGUAGES`, `LLM_MODELS`, `VOICE_EMOTIONS`, `VOICE_MODELS`, `canonicalSettings()`/`settingsHash()`, `group_id` · [AiCallKbEntry.php](/src/VoiceAgent/AiCallKbEntry.php) · [AiCallChatTest.php](/src/VoiceAgent/AiCallChatTest.php) (kept Test-LLM sessions) · [AiCallBlockedNumber.php](/src/VoiceAgent/AiCallBlockedNumber.php) (`covers()`)
- Repositories: [AiVoiceCallRepository](/src/VoiceAgent/Repositories/AiVoiceCallRepository.php) (`createOutbound` whitelist, `settle`, `mergeMeta`) · [AiCallProfileRepository](/src/VoiceAgent/Repositories/AiCallProfileRepository.php) (hash-gated version bump) · [AiCallChatTestRepository](/src/VoiceAgent/Repositories/AiCallChatTestRepository.php) · [AiCallBlockedNumberRepository](/src/VoiceAgent/Repositories/AiCallBlockedNumberRepository.php) · [Facades](/src/VoiceAgent/Facades/)

**The behaviour**
- [Services/RetellAgentSync.php](/src/VoiceAgent/Services/RetellAgentSync.php) — ALL provider artifact creation/versioning; the version traps live in its docblock
- [Services/AiCallAftermath.php](/src/VoiceAgent/Services/AiCallAftermath.php) — the ONE owner of a call's consequences
- [Support/RetellCallMapper.php](/src/VoiceAgent/Support/RetellCallMapper.php) — one mapper for webhook AND reconcile
- [Support/AiCallPresenter.php](/src/VoiceAgent/Support/AiCallPresenter.php) — THE shared row shape + batched `deliveryMap()`
- [app/Actions/PlaceAiVoiceCall.php](/app/Actions/PlaceAiVoiceCall.php) — the one entry point, and every guard
- [app/Jobs/Automation/SendAiCallFollowUp.php](/app/Jobs/Automation/SendAiCallFollowUp.php) — the WhatsApp loop; payload constants `PAYLOAD_ACK` / `PAYLOAD_CALL_AGAIN` live here, not in the doc
- [app/Jobs/Webhooks/ForwardRetellWebhook.php](/app/Jobs/Webhooks/ForwardRetellWebhook.php) — the cross-environment relay

**HTTP**
- [Webhooks/RetellWebhookController.php](/app/Http/Controllers/Webhooks/RetellWebhookController.php) + [tests/Feature/VoiceAgent/RetellWebhookTest.php](/tests/Feature/VoiceAgent/RetellWebhookTest.php)
- [Manage/Calls/AiCallsController.php](/app/Http/Controllers/Manage/Calls/AiCallsController.php) · [Manage/Calls/AiProfilesController.php](/app/Http/Controllers/Manage/Calls/AiProfilesController.php)
- [Main/AiCallbackController.php](/app/Http/Controllers/Main/AiCallbackController.php) — the public "call me now" door: a `Crypt` token carrying lead + profile slug + a **7-day** expiry, one call per lead per **10 minutes**, `throttle:6,1`, rendering `calling` / `already_calling` / `quiet_hours` / `failed` / `invalid`
- Form Requests: [Manage/Calls/AiCallQueryRequest.php](/app/Http/Requests/Manage/Calls/AiCallQueryRequest.php) and the seven in [Manage/Calls/AiProfile/](/app/Http/Requests/Manage/Calls/AiProfile/) — Store/Update hold the practical limits (prompt cap, KB entry cap, the `LANGUAGES`/`LLM_MODELS`/`VOICE_*` whitelists); CloneVoice holds `SAMPLE_MIMES`

**Console** (`app/Console/Commands/`, + the [Kernel](/app/Console/Kernel.php) schedule entry)
- [ReconcileAiVoiceCalls.php](/app/Console/Commands/ReconcileAiVoiceCalls.php) `voice-agent:reconcile` — every 10 min
- [BackfillAiCallFollowUps.php](/app/Console/Commands/BackfillAiCallFollowUps.php) `voice-agent:backfill-followups`
- [DiagnoseAiCallFollowUps.php](/app/Console/Commands/DiagnoseAiCallFollowUps.php) `voice-agent:followup-doctor`
- [TestAiVoiceCall.php](/app/Console/Commands/TestAiVoiceCall.php) `voice-agent:test-call`

**Frontend**
- [Pages/Manage/Calls/AiCalls/Index.vue](/resources/js/Pages/Manage/Calls/AiCalls/Index.vue) — the ledger + the WhatsApp delivery column
- [Pages/Manage/Calls/AiProfiles/](/resources/js/Pages/Manage/Calls/AiProfiles/) — `Index.vue`, `Show.vue`, `Partials/ProfileForm.vue`, `ProfileFormModal.vue`, `VoiceRecorder.vue`, and `Partials/Tabs/{Settings,TestLab,Calls,Copilot}Tab.vue`. **`TestLabTab.vue` owns the mic pre-flight and the billed-call teardown** — both cost money if broken.
- [utils/voiceCloneScript.js](/resources/js/utils/voiceCloneScript.js) — the read-aloud cloning script · [utils/messageStatus.js](/resources/js/utils/messageStatus.js) — the delivery ticks

**Prompts + config**
- [resources/prompts/voice_agent_guard.md](/resources/prompts/voice_agent_guard.md) · [voice_agent_guard_yue-CN.md](/resources/prompts/voice_agent_guard_yue-CN.md) · [voice_agent_copilot.md](/resources/prompts/voice_agent_copilot.md), registered in [config/ai_prompts.php](/config/ai_prompts.php) (`AiRequest::PROMPT_VOICE_AGENT_GUARD` / `_COPILOT`). ⚠️ Bodies are admin-editable on AI Prompts — **the repo file is the default, not the live text.**
- [config/services.php](/config/services.php) → the `retell` block:

| key | env | default | what it costs if wrong |
|---|---|---|---|
| `api_key` | `RETELL_API_KEY` | — | also the webhook signing secret |
| `webhook_url` | `RETELL_WEBHOOK_URL` | this app's route | a non-prod box hijacks production's events |
| `webhook_forward_url` | `RETELL_WEBHOOK_FORWARD_URL` | null | the other environment sees nothing live |
| `max_call_minutes` | `RETELL_MAX_CALL_MINUTES` | 5 | a hard cut with no goodbye |
| `daily_budget_usd` | `RETELL_DAILY_BUDGET_USD` | 15 | 0 disables the fuse entirely |
| `combined_per_minute_usd` | `RETELL_PER_MINUTE_USD` | 0.25 | prices unsettled calls for the budget check |
| `ring_seconds` | `RETELL_RING_SECONDS` | 25 | too long starts the meter on voicemail |
| `begin_message_delay_ms` | `RETELL_BEGIN_DELAY_MS` | 1000 | 0 talks over the customer's "Hello?" |
| `interruption_sensitivity` | `RETELL_INTERRUPTION_SENSITIVITY` | 0.3 | **never 0** — uninterruptible for the whole call |
| `model_high_priority` | `RETELL_MODEL_HIGH_PRIORITY` | true | dead air on a live call |
| `quiet_hours_enabled` | `RETELL_QUIET_HOURS` | **false** | night calling; a code-of-practice market needs it true |

**Migrations** — `grep database/migrations -e ai_call_profiles -e ai_voice_calls` (20 files). The four carrying design notes worth reading: `2026_08_18_150000_create_ai_voice_calls_table`, `2026_08_19_090000_add_test_runs_and_settings_version_to_voice_agent`, `2026_08_19_120000_add_call_guards_to_voice_agent` (+ `ai_call_blocked_numbers`), `2026_08_30_170000_add_group_id_to_ai_call_profiles_table`.

**Credentials:** the [Delivery APIs](/docs/modules_handbook/shared/messaging-credentials/readMe.md) card (`MessagingCredential::PROVIDER_RETELL`).
