# 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`, `webhooks.retell.tool.check-time`, `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.
- **A campaign's GOAL is a standard service, not a prompt paragraph (2026-09-08).** Every
  appointment-setting brain used to carry the same machinery hand-copied into its prompt — "the one
  goal is to lock an appointment", the two-slot invitation, the lock-the-slot ritual, the pushback
  answers, the repeat-call rule — plus the extraction fields the booking capture reads; three Armani
  Hallson brains held three drifting copies, and a brain that forgot `appointment_time` passed Save
  and then never booked. Now `ai_call_profiles.objective` (json, nullable) names a goal from
  `AiCallProfile::GOALS` (`appointment` today; the registry is shaped for more) and its settings:
  **push** (a 0–100 slider stored as `push_pct`, rendered as the `PUSH_LEVELS` band it falls in —
  soft / balanced / assertive, each a FIXED rule set the form shows under the slider and the prompt
  receives verbatim, because a model cannot act on "63%" but can act on "propose twice, then stop";
  this is how hard the agent pushes WITHIN one call — how many calls follow is the workflow's retry
  ladder), **where** the appointment happens (`MEETING_TYPES`: showroom / Zoom / both, plus the
  default when the customer never says; a single-channel goal pins its default), **how times are
  offered** (`SLOT_MODES`: `open` = a two-option choice inside the hours and horizon, then a clock
  time; `fixed` = up to `MAX_SLOTS` admin slots offered two at a time — dated facts, the form says
  they go stale), appointment **hours + horizon days**, the **confirmation ritual** (announce the
  WhatsApp confirmation, ask for a YES) and the **no-booking fallback** (`FALLBACKS`: call-back time /
  details on WhatsApp / both / none). `AiCallProfile::canonicalObjective()` is the ONE normaliser
  (fixed key order, clamped, whitelisted; null for no goal, never an empty object) that the hash,
  the form payload and the renderer all read. **Code:** `Src\VoiceAgent\Objectives\CallObjectives::for($profile)`
  resolves the `CallObjective`; `AppointmentObjective` renders the admin-editable skeleton
  `voice_agent_objective_appointment` (AI Prompts page — its `{single_brace}` placeholders are filled
  from the settings, the `{{double_brace}}` ones are left for the provider; written as behaviour,
  never speakable lines, so one body serves every language), and `RetellAgentSync::composedPrompt()`
  appends it behind `OBJECTIVE_MARKER` — after the admin's prompt, BEFORE the guard; `stripManaged()`
  cuts both markers off on Pull/Import. `extractionWithGlobals()` appends the goal's fields
  (`appointment_time` carrying its own ISO 8601 guidance, `meeting_type`, `appointment_booked`,
  `callback_time`, `main_objection`, `wants_human`) after the profile's own — a same-named own field
  wins, so an admin can still reword one — and `pull()` drops those managed names again unless the
  profile declared them itself. The goal is part of `canonicalSettings()` (the key is present only
  when a goal is set, so every goal-less profile keeps its hash and mints no phantom version), so a
  slider change bumps the settings version and every call snapshot records the push level it ran on.
  **Consumers:** the AE booking writer (`AiCall::bookIfPromised`) reads the goal's
  `default_meeting_type` after the lead's own answer and before the step's default;
  `Workflow::aiCallProblems()` accepts a goal-bearing profile without reading its field list
  (`AiCall::APPOINTMENT_KEYS` IS `AppointmentObjective::TIME_ALIASES`, and `AiCall::parseWhen()`
  delegates to the objective's ladder); `RetellCallMapper::outcome()` asks
  `AppointmentObjective::verdict()` (`CallObjective::RESULT_MET / CALLBACK / HUMAN / DECLINED /
  UNCLEAR`) before its free-text heuristic. The copilot's snapshot carries the managed goal and its
  prompt tells it never to write those mechanics into the prompt. **Opener disclosure:** the goal's
  block orders the agent to disclose it is an AI in its first full sentence if the opener did not;
  `AiCallProfile::openerDisclosesAi()` feeds an amber warning on the form and the Settings tab
  (the provider's terms want it in the opener itself — the warning does not block). **Why the goal
  lives on the PROFILE while the chat goal lives on the AE plan** (`Plan::CHAT_GOALS`): a voice
  brain is chosen per call step AND by CRM callers (`PlaceAiVoiceCall`) that have no plan, and the
  two channels never meet on one message — each has its own capture mechanics. **Not built yet:**
  the CRM-side booking writer (a `PlaceAiVoiceCall` consumer's agreed slot still lands only in the
  lead's Discussion comment, not in `appointments`; the hook is `AiCallAftermath::analyzed()` with
  `AppointmentObjective::parseWhen()` / `channelFrom()`, plus a provenance column on
  `appointments`), and a `check_slot` custom function for availability, the same shape as `check_time`.
- **The offset is the model's; the clock time is the customer's (2026-09-15)** — Retell's analysis
  model stamped a Malaysian call's agreed "Friday 12pm" as `2026-09-18T12:00:00-07:00` (its own
  Pacific default) and the engine booked Saturday 3am. `AppointmentObjective::parseWhen()` now reads
  an ISO stamp as the wall-clock time in `app.user_timezone` and drops whatever offset it carries;
  `foreignOffset()` names a foreign one so the AE run trail can say why the booked hour differs from
  the raw value. `isoClause()` is the one sentence every booking-time extraction field carries
  (the goal's own field and `RetellAgentSync`'s payload guidance): ISO 8601, in the market's named
  timezone, with its offset spelled out. Payload wording reaches an agent on its next save; the
  parser covers agents synced before.
- **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`.
- **The agent has no clock, so it is given one — `check_time` (2026-09-06).** Retell feeds no
  elapsed time to the LLM (the community's `{{session_duration}}` folklore is confirmed not to
  reach the model), so "wrap up before N minutes" in a prompt asks for something the model cannot
  know; the RM100 Bonus follow-up hit its 5-minute fuse mid-sentence, with the customer talking.
  `RetellAgentSync::generalTools()` puts TWO tools on every voice llm, on create AND every update
  (so a Sync repairs an imported llm that arrived with none — one live campaign had no `end_call`
  at all and could never hang up): **`end_call`**, whose description and the guard both say *same
  turn as the goodbye, do not wait for a reply* (Retell's docs: the agent never ends a call unless
  told when; the community measures a 10–15% miss on "say goodbye then end" phrasing); and
  **`check_time`**, a custom function POSTing to `webhooks.retell.tool.check-time`
  (`RetellToolController`), which reads `call.start_timestamp` off the request and answers
  `elapsed_minutes` against the profile's **`target_call_minutes`** (soft budget, nullable) and
  `max_call_minutes` (the fuse) with a `status` the guard prompt acts on: `on_track` continue;
  `over_target` open no new topic, ask the one most important unanswered question, wrap up;
  `wrap_up_now` (≤ 1.5 min before the cut) summarise, next step, goodbye, `end_call`. The tool
  owns only the numbers; the guard owns the behaviour. Its url is minted from the WEBHOOK
  receiver's origin, never `route()` — shared agents must check production's clock. ⚠️ Retell
  documents no signing scheme for custom functions, so the endpoint accepts a request whose
  signature fails when its `call_id` names one of our calls that is still ringing or in progress
  (a random id that lives only for the call and unlocks only that call's elapsed minutes); the
  miss is logged with the header's SHAPE so the scheme can be pinned once seen. A prompt that
  merely says "wrap up in time" is the smell this replaces.
- **⚠️ 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.

**Giving a brain a goal (2026-09-08):** pick *Schedule an appointment* in the profile form's Goal
section (or write `objective` through `AiCallProfileRepository::update()` —
`['ai_call_profile' => ['objective' => ['goal' => AiCallProfile::GOAL_APPOINTMENT, 'push_pct' => 50, …]]]`,
the full `kb_entries` list alongside as always), then Sync. The prompt then only has to say who the
brain is and what it pitches — the goal appends the booking mechanics and the extraction fields.
Reading a call's result from code, whatever brain placed it:

```php
use Src\VoiceAgent\Objectives\CallObjective;
use Src\VoiceAgent\Objectives\CallObjectives;

$verdict = CallObjectives::for($profile)?->verdict($call->analysis['custom_analysis_data'] ?? []);
// CallObjective::RESULT_MET | RESULT_CALLBACK | RESULT_HUMAN | RESULT_DECLINED | RESULT_UNCLEAR | null (no goal)
```

**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`, `GOALS` / `PUSH_LEVELS` / `MEETING_TYPES` / `SLOT_MODES` / `FALLBACKS` + `canonicalObjective()` / `pushLevel()` / `objectiveGoal()` / `openerDisclosesAi()` / `objectiveOptions()`, `canonicalSettings()`/`settingsHash()`, `group_id`
- [Objectives/](/src/VoiceAgent/Objectives/) — `CallObjective` (the interface + `RESULT_*`), `CallObjectives` (the resolver), `AppointmentObjective` (the prompt renderer, the goal's extraction fields, `verdict()`, `channelFrom()`, the `parseWhen()` ladder, `TIME_ALIASES`) — see the GOAL bullet above · [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)
- [Webhooks/RetellToolController.php](/app/Http/Controllers/Webhooks/RetellToolController.php) — the mid-call custom functions (`check_time`) + [tests/Feature/VoiceAgent/RetellToolTest.php](/tests/Feature/VoiceAgent/RetellToolTest.php)
- Tests for the goal: [tests/Unit/VoiceAgent/AppointmentObjectiveTest.php](/tests/Unit/VoiceAgent/AppointmentObjectiveTest.php) (normaliser, bands, placeholders, verdict — pure) + [tests/Feature/VoiceAgent/CallObjectiveTest.php](/tests/Feature/VoiceAgent/CallObjectiveTest.php) (registry render, sync composition + strip, extraction merge, version bumps, the parse ladder)
- [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, the `objective.*` rules); 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`. `ProfileForm.vue`'s **Goal section** (select → slider + settings, fed by the `objectiveOptions` prop = `AiCallProfile::objectiveOptions()`) and `SettingsTab.vue`'s **Goal card** exist twice — the Appointment Engine's `Pages/Manage/AppointmentEngine/Calls/AiProfiles/Partials/` copies differ only in import paths / URLs / the language field; change both. **`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_objective_appointment.md](/resources/prompts/voice_agent_objective_appointment.md) (the appointment GOAL's skeleton — keep its `{placeholders}`) · [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` / `PROMPT_VOICE_AGENT_OBJECTIVE . '_appointment'` / `_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` | 15 | a hard cut with no goodbye — and every RINGING call reserves this × the per-minute rate against the daily budget, so a high fuse lets fewer calls run at once |
| `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`, `2026_09_06_100000_add_target_call_minutes_to_ai_call_profiles` (why a soft budget needs a tool, not a prompt line), `2026_09_08_100000_add_objective_to_ai_call_profiles` (why a goal is a service, not a prompt paragraph).

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