# AI Voice Agent · AI Profiles (campaign authoring)

Sibling of [readMe.md](/docs/modules_handbook/shared/voice-agent/readMe.md) — how an admin
AUTHORS and TESTS a campaign, as opposed to how a call runs. **Nav:** Phone Call → **AI
Profiles** (`manage.calls.ai-profiles.index`, a drill-in from the AI Calls page, not a tab) +
**AI Profile · Show** (`manage.calls.ai-profiles.show`, §14 ShowTabs: Settings · Test lab ·
Lead calls). `GET ai-profiles/{id}` is declared LAST so literal segments are not swallowed as
a uuid, and `destroy` redirects to the index rather than `back()`.

A profile is a named, admin-editable campaign — purpose, prompt, opening line, knowledge,
voice, language, brain, tone, extraction schema, **two time budgets** (Max length = the
provider's hard cut, no goodbye; Target length = the soft budget the agent's `check_time` clock
paces against — see the main doc) — mapped 1:1 onto a Retell agent + LLM +
knowledge base by `RetellAgentSync`. Features place calls by SLUG, so a campaign can be
re-worded, re-synced, paused or replaced without any caller changing code.

⚠️ **Saving is not publishing.** Any content edit clears `synced_at`; the identity card turns
that into a **Sync to publish these edits** button. The copilot's own prompt says the same,
so it never implies the job ends at Save.

## The authoring surface

- **In-CRM editing beyond prompt/KB (2026-08-19):** the profile form also carries
**voice** (any Retell voice — cloned voices flagged ★ — with an audio preview) and
**speed** (0.5–2.0×, stored ×100 as `voice_speed_pct`); both null = inherit the tuned
template agent, and both ride the same Sync (patched onto the agent draft). A **Test LLM**
button opens a text chat drawer that talks to a per-profile **chat agent**
(`retell_chat_agent_id`) running its OWN mirror llm (`retell_chat_llm_id`), re-synced from
the same composed prompt + KB on every Sync — **never** the voice `retell_llm_id`, see the
⚠️ below — so it exercises the real prompt + knowledge as text, no phone, near-free (`create-chat` → `create-chat-completion`; routes
`ai-profiles.chat.{start,send}`). The chat agent is created best-effort during Sync and
never fails a voice sync. **Test Audio** (2026-08-19)
talks to the VOICE agent from the browser over WebRTC: the backend mints an access token
(`POST /v2/create-web-call`, route `ai-profiles.web-call`, throttled — Retell bills the
minutes, no Twilio leg) and the page runs `retell-client-js-sdk` (dynamic import — livekit
stays a lazy ~530KB chunk, loaded only on Start call) with live transcript + speaking
indicator. Web test calls ARE recorded in `ai_voice_calls`: `webCallStart` creates the row with
`source = SOURCE_WEB_TEST` and `to_number = null` BEFORE the `create-web-call` request, then
stamps the returned `call_id`, so the webhook finds it by `provider_call_id` and settles it
like any other call. Every aggregate on the AI Calls page scopes on `source` so a browser
test can never reach a count, an average or a cost total.

**Kept test runs + settings versions (2026-08-19).** A profile carries a
`settings_version` counter + `settings_hash`; `AiCallProfile::canonicalSettings()` is the ONE
normaliser both the hash and every snapshot go through (prompt, opening line, voice, speed,
knowledge as `{seq,title,content}` — deliberately NOT `name`/`purpose`, which never reach
Retell, so a rename mints no version). The repository bumps only when that hash changes, inside
its existing transaction — the profile row IS the lock, so none of `AiPromptRepository`'s
`max(version)+1` + retry machinery applies here. There is **no versions table**:
`membership_versions` was exactly that design and was flattened away as over-engineering, so
this follows the WhatsApp-flow-run pattern instead — every call and chat test freezes
`settings_snapshot` + `settings_version` + a hash **recomputed from its own snapshot** (agreeing
by construction, not assertion). Rows predating this read "Legacy", never v1.

Browser **Test Audio** runs are stored in `ai_voice_calls` — a web call IS a Retell call, so the
webhook, `RetellCallMapper` and the reconcile sweep all serve it unchanged — separated by
`source` (`SOURCE_LEAD` / `SOURCE_WEB_TEST`). That column must reach **every** aggregate: the AI
Calls list, its status counts and total, the per-profile call count, and — most importantly —
the copilot's evidence window, which takes the newest 5 calls and would otherwise advise on the
admin talking to themself. `to_number` is nullable because a browser test dials nobody. Text
**Test LLM** sessions are NOT calls (Retell's chat resource has no webhook, recording, analysis,
duration or cost, and `get-call` does not know it) and live in their own `ai_call_chat_tests`
table; stored as calls the reconcile sweep would force-fail every one at the six-hour mark. The
mapper also now captures Retell's own `agent_version` — authoritative for what actually spoke,
kept separate from our `settings_version`, because the two disagreeing is a real bug worth
seeing.

**Cloning a voice from the CRM (2026-08-19).** The profile form's Voice section can upload a
recording and clone it: `POST ai-profiles/clone-voice` (account-level — Retell owns voices, so
no `{id}`) streams the upload to `POST https://api.retellai.com/clone-voice`. The contract was
established empirically, because Retell documents no audio constraints: **multipart**, fields
`voice_name` + `voice_provider` (`minimax` | `elevenlabs` | `cartesia` | `fish_audio` |
`platform`) + the file under **`files`** — that exact field name, anything else 500s. MiniMax
(what this account's working clone uses, and what handles Mandarin) accepts **one** file of
**10–300 seconds**; the provider's own duration error is passed through verbatim rather than
re-guessed here, since it names the actual length. Cloning is synchronous — budget ~15s. The
sample is deliberately **not** stored: Retell already holds it, and a voice sample is
biometric-adjacent personal data, so a second copy would be another place to leak it from for
no reader; the actor is recorded in the log instead.

⚠️ **A clone is permanent, and 100 is the cap.** Retell publishes no delete-voice endpoint —
both plausible routes 404 and neither official SDK defines one — so an admin iterating on a
sample spends quota that cannot be reclaimed. The form says this *above* the upload button, with
a live used-count, rather than after. Also worth knowing: `platform` clones get automatic TTS
fallback while the other providers need it configured by hand, and the live `list-voices`
returns a `voice_type: "custom"` field the SDK schema does not document — which is what the ★
flag in the picker reads.

**The Goal section (2026-09-08).** Under the opening line the form carries a **Goal** select —
none, or *Schedule an appointment* — and, once picked, the goal's settings: the **push slider**
(0–100 → Soft / Balanced / Assertive, the band's actual rules printed under it, the same text the
prompt receives), where the appointment happens, how times are offered (open, or up to three fixed
slots), appointment hours + horizon, the no-booking fallback and the WhatsApp-confirmation ritual.
An opener that does not say "AI" gets an amber warning here and on the Settings tab's Goal card.
Saving stores `objective` (see the main doc's GOAL bullet for the contract); Sync appends the
rendered block and the goal's extraction fields. **What to leave OUT of the prompt from now on:**
the goal statement, the two-slot invitation, the lock-the-slot ritual, the "how many times to ask"
discipline and the repeat-call rule — they now arrive from the goal, and a prompt that repeats them
runs them twice (and drifts). The copilot is told the same. The `objective` key is not part of a
copilot proposal, so Apply & Sync never touches it.

**Structured extraction is per-profile (2026-08-19).** `ai_call_profiles.extraction_fields` (json)
holds what each campaign pulls out of every call — `{name, type, description, choices?}`, ≤12,
types `string|enum|boolean|number`. It is **not in the prompt**: the prompt is what the agent
SAYS, this is what comes back as filterable data (`ai_voice_calls.analysis`). Normalised by
`AiCallProfile::canonicalExtraction()`, which feeds BOTH the settings hash and the provider
payload, so it versions like every other behaviour change and a stored call's snapshot records
what it was extracting.

This replaced a silent bug: `post_call_analysis_data` was copied from the TEMPLATE agent on
create and **re-copied on every Sync**, so a new campaign inherited the first campaign's
questions whatever its own script asked — bootcamp-welcome shipped extracting Cochrane's
`preferred_date` / `preferred_slot` / `preferred_project` and answered them blank forever, with
nothing on screen to say so. Now the profile's own schema wins (the template is only the fallback
for a profile that defines none), it is patched onto the draft in `updateExisting` alongside
voice/speed, and the Settings tab shows it in a **"What the AI will find out"** card — including
a heuristic warning when a field's name/description does not appear anywhere in the prompt, since
a field the conversation never raises returns empty on every call and errors nowhere.

**Cross-environment: Import + Pull (2026-08-19).** Dev and production run separate databases
but ONE Retell account, so the provider is the shared source of truth: dev authors and **Sync**s
(push); production **Import**s a not-yet-linked agent (index header button — lists `list-agents`
deduped to latest version, retell-llm only, minus already-linked ids) and thereafter **Pull**s
(Show header) to refresh. `RetellAgentSync::fetchRemote()` reads the whole published state back —
agent (voice/speed/extraction) + LLM (prompt/opening line) + **KB texts downloaded via each
source's `content_url`** (round-trips byte-identical; a failed text download aborts the pull,
because a half-imported KB would be pushed back over the full one on the next Sync). Both flows
write through the normal repository, so an import baselines at v1 and a pull bumps the version
only when content actually changed; name/purpose stay local (labels — Retell's copy carries the
`peta:` prefix). Two facts to keep in mind: a pull on a profile that INHERITS voice/speed pins
them to the live agent's explicit values (same sound, now fixed — a one-time version bump), and
**Delete on an imported profile deletes the shared live agent** the other environment still
manages — the confirm modal says so, but the workflow rule is: production pulls, only dev deletes.
Don't edit-and-Sync the same profile from both sides; last push wins.

⚠️ **An EMPTY knowledge download is a failed download (fixed 2026-09-15).** Retell stops serving
the original text of an older KB source: its `content_url` still answers **HTTP 200 with zero
bytes**, while the KB itself stays `complete` and the live agent keeps answering from its index.
`downloadKbTexts()` used to check only the status, so a Pull stored every such entry as an empty
string — it wiped the knowledge of profiles #1–#4 on a dev box in one click, and a Sync
afterwards would have pushed the blanks over the live agents. It now throws on a blank body, and
the Pull is refused with "Retell did not return the text of knowledge entry …" and changes
nothing. Consequence to know: **those four profiles can no longer be Pulled at all** (their prompt
and voice cannot be refreshed that way either) until their knowledge is re-uploaded by a Sync from
an environment that holds the text. A title-only entry (kept by `formatEntries()`) syncs as an
empty text and would trip the same refusal — the safe direction to be wrong in. Pinned by
`tests/Feature/VoiceAgent/RetellPullTest.php`.
