0083 — A Clara line answers through our front door, and a call Clara cannot take reaches the company's people
- Status: Accepted 2026-11-03. Written while the slices are in flight, as ADR-0004 requires.
- Date: 2026-11-03
- Deciders: Gera, 2026-11-03 ~08:00Z, after the overnight outage: "if Clara's phone call fails, the caller goes straight to that company's call center (MCC) or on-call list", "go, build layer 2". The stale-health-row rule below was decided by Fable (receipt
~/.claude/jobs/fable/f08cc0f7f.json, RESOLVED option A, held under reversal), per Gera's rule that low-risk technical calls go to Fable. - Systems: clara-front-door (new; propflowai
POST /api/twilio/voice/clara, built in propflowai #24820 (route, catch, chain, record) and #24821 (the health row); the admin section and the route's read of the health row follow as their own PRs). It changes how Twilio, our app and ElevenLabs relate on every Clara phone line.
1. The problem
On 2026-11-03 from ~06:20Z the ElevenLabs workspace was on the free tier with its credits used up. Every inbound Clara call failed with error 3000 quota_exceeded (call_initialization_error), and the caller was hung up on within a second (Twilio call CAab3ababaaf8c18ae5829cbcb5d370acc, 0s).
Every customer line's Twilio VoiceUrl pointed directly at ElevenLabs' native endpoint (https://api.elevenlabs.io/twilio/inbound_call; one line uses api.us.elevenlabs.io). Our app was not in the path, so nothing of ours could notice or fall back. Twilio's VoiceFallbackUrl would not have helped either: ElevenLabs answered the webhook normally and failed only after the stream connected. A session hand-forwarded the four customer lines to twimlets.com chains as a stopgap.
2. The decision
- A Clara line's
VoiceUrlpoints at our route,/api/twilio/voice/clara(?region=usfor a line bound to the US endpoint). The route POSTs Twilio's own webhook form to the same ElevenLabs endpoint the line used to name, so Clara's personalization is unchanged, and returns the<Connect><Stream>it gets back with one change: anactionon<Connect>that points back at us. - The post-connect signal is the
<Connect action>plus the ElevenLabs conversation status. Proven live on the test line on 2026-11-03 while credits were out: when ElevenLabs closes the stream, Twilio requests the action withCallStatus=in-progressand plays its TwiML (callCA191a710aa135c889cf597b0bc821d6eb), and the conversation named on the stream already readsstatus: failed, error 3000.failedorinitiated→ the fallback (since §5: the caller is asked what is going on; an emergency reaches a person, anything else is recorded). Any other status → hang up, as the end of the document did before. Status unreadable → a stream under 15 seconds counts as failed. A transfer or hang-up ElevenLabs makes over Twilio's REST API replaces the document, so the action never runs for it. The route only ever answers the action with TwiML; it never redirects a call over REST. - Who the caller reaches is the company's existing settings, never a number of ours. (Superseded in part by §5: the caller is asked first, only an emergency rings anyone, and the call center is out of the fallback.) One function,
claraFallbackTargets(propflowaisrc/lib/domain/voice/clara-fallback-chain.ts), readsthe call center (the on-call list (maintenance.intake_route, target external), thenemergency.route.residential, with the PMS on-call person first where the company turnedemergency.live_oncall_firston), then a message ("text this number, or 911"). A company with nothing on file gets the message. A rung that is on file but unusable (an unreadable row, a number that loops back into Clara, an external target with no number) is skipped by the existing resolvers, and the next rung rings; a rung that rings out, is busy or fails moves on the same way. The company's own on-call people see the dialled Clara line as caller ID (the 2026-10-21 hand-off rule,handoffShows). The admin page section and its CLI read the same function, so there is no second copy of the routing (their wording for the §5 order is part 3, pending). - Every fallback is recorded and alerted. A call that falls back gets its own conversation (
conv-clara-fallback-<CallSid>, listed on /conversations) and one #alerts card naming why and linking that record (alertSource: clara_front_door_fallback). - No switch. It is a fix to a live failure, so it is the only path (propflowai CLAUDE.md hot rule 8).
- A cached ElevenLabs health pre-check (propflowai #24821 writes the row every minute; the route reads it in a follow-up PR) skips the ElevenLabs round trip when the workspace is known to be out of credits. It is read from a row refreshed every minute, never fetched during a call. "Out" means every character of the period used AND no usage-based overage (
can_extend_character_limitnot true): a paid workspace that bills overage keeps taking calls. A stale or unreadable row means "send the call to Clara" (Fable f08cc0f7f): the catch in decision 2 is the safety net, and a false "unhealthy" from a dead cron would silently send every caller past Clara. A stale or unreadable row posts to #alerts. - When our app is down, Twilio's
VoiceFallbackUrlcovers it, and it must not depend on our app. It points at a static twimlets message with no dependency on us (the per-line values are in the #24820 PR body, Cutover section).
3. What this does not do
- It does not change Clara herself: the prompts, the personalization webhook and the transfer tools are untouched.
- It does not cut any customer line over. Changing a customer line's
VoiceUrlis a third-party production config write, done by the main session at cutover. - A rung answered by a voicemail box counts as answered, the same rule the office-hours wrap uses. Settled, not a follow-up: Gera, 2026-11-03, on answering-machine detection: "Leave it as is." The emergency ring is a plain
<Dial>. A call handed BACK to Clara after an unanswered office-hours wrap or connect dial still redirects straight to ElevenLabs.Done in propflowai #24874:claraTwiml()now hands the call back through the front door, so it gets the catch too.
4. Consequences
- Our app is now in the path of every Clara call that is cut over. The added time to first audio on a healthy call is measured on the test line and recorded in the propflowai PR. An outage of our app is covered by decision 7.
- The ElevenLabs workspace being out of credits is now a degraded service (callers reach people), not an outage.
5. Amendment 2026-11-03 — smart triage: ask first, only an emergency rings anyone
- Deciders: Gera, 2026-11-03, in chat (relayed by the main session): the fallback asks what is going on, decides script first against the company's own emergency list, rings the on-call list only for an emergency, and takes a message for everything else; the call center drops out of the fallback.
- Built in: propflowai #24896 (part 1: ask, script triage, on-call ring, message, alert chain). Parts 2 (AI fallback for an unsure answer) and 3 (the admin section and CLI show the new order) follow as their own PRs.
The flow, replacing decision 3's ring order:
- Ask, without ElevenLabs. A Twilio
<Gather input="speech dtmf">in the Polly.Joanna-Neural voice: "Thanks for calling. In a few words, what's going on? If this is an emergency, say 'emergency' or press 1." No "problem", no "sorry". Nothing is looked up before the question; the record and #alerts card (decision 4) are written right after it is asked, so a caller who hangs up is still on record. - Script first. An emergency is the word "emergency", the 1 key, a hazard said plainly (gas, smoke, fire, flooding, someone hurt…), or most of the content words of an entry (matched up to their endings, so "flooded" is "flood") on the company's own emergency list (
readEmergencyDefinition, the list Clara's intake reads) or the always-an-emergency floor (EMERGENCY_CLASSES). One word of an entry alone is a hint, never a verdict ("my water bill" is not an emergency); that is unsure. Today (part 1) unsure is settled by a script rule: urgent-sounding words (leak, pouring, no heat, help, urgent…) make it an emergency. Part 2, pending: an unsure answer goes to the existing Claude client once, with a short budget, and every such use posts to #alerts with why (Gera's "script first, AI as a fallback wrapper"); an answer the AI cannot settle falls back to the same urgent-sounding rule. - Emergency → the on-call list (
emergency.route.residential, PMS on-call person first where turned on), a plain<Dial>per person, no machine detection. Nobody answers, or the caller hangs up while it rings → the company's existing emergency alert chain (startEmergencyAlertChain) starts on the call's record, and a caller still on the line is told to call 911 if anyone is in danger and asked to leave a message. No on-call list on file (e.g. JP&CO) → the caller is told to hang up and dial 911 if anyone is in danger (our route never dials 911), then a message, and the chain starts (it posts to #emergency even when it has nobody to ring). - Anything else → a message. "Please leave a message after the tone and the team will get back to you." Recorded and transcribed; the words land on the call's record through the caller-left-a-note path (
handleCaptureUnknownCallerNote: the note, the team email, the review card), and the recording plays from the thread. The transcript callback is recognised by its payload, never its URL, because Twilio may drop a callback's query. - Silence → asked once more, then a message. Never the on-call list: a silent call is far more often a callbox or a pocket dial than an emergency.
- The call center is out of the fallback.
claraFallbackTargetsno longer returns it; it is still read only so a number it shares never shows the line's caller ID. A company that chose a call center for emergencies still has the emergency alert chain route there: that is the chain's own rule.
Also shipped beside it: a proactive ElevenLabs credit alert (propflowai #24877): the every-minute health cron posts one deduped #alerts card when the workspace is out of credits, under 10% left, on the free plan, or unreadable for five minutes, and closes it after 30 healthy minutes, so the next credit outage is seen before a caller finds it.
What the guarantee is now: an emergency reaches a person (or, if nobody answers, the emergency alert chain and a recorded message); anything else is recorded and reaches the team as a message. The title's "reaches a person" holds for emergencies.
Proof: tests and TwiML walks with synthetic Twilio parameters only. From 2026-11-03 no robot calls go into any test line; live proof of the triage path waits for the first real call through the fallback. Watch first on that call: (1) whether Twilio's speech result matches what the caller said closely enough for the script (the The caller said: line on the record); (2) that the transcript callback lands as a note on the record; (3) for an emergency nobody answers, or a caller who hangs up while it rings, that the alert chain started (the #emergency post).