FuneralWiseAI — At-Need Director Alert-Call (MVP) Expected Output Spec
STATUS: DRAFT — NOT APPROVED. CLAUDE.md Rule #17 (HARD GATE) is NOT satisfied.
Stage-1 agent draft, pre-populated from the founder's signed-off design (2026-07-02), the live schema (verified below), the funeral safety-logging design (
products/funeralwiseai/safety-logging-design.md, thecrisis_eventsno-FK tenant shape this reuses), and the funeral vertical spec. Do not build until the founder approves and the §11 open items are confirmed in a Stage-2 interview.
AI Bridge Principle. This feature IS the bridge, at its most literal: an AI capture becomes a real human director on a real phone as fast as possible. The AI never handles the at-need family itself — it escalates to a human. See
architecture/ai-bridge-principle.md.
0. Scope — what this feature is (and is NOT)
In scope (MVP):
- A DB-trigger → authed-endpoint → Telnyx Call Control outbound call path that phones the on-call funeral director when an at-need callback is captured, from either channel (voice agent or chatbot), with zero change to the voice agent.
- A warm ~25s TTS summary read on answer, a DTMF ack (1 = acknowledge, 2 = repeat, 3 = bridge to the family), and answering-machine detection that leaves a voicemail but does not count as an ack.
- An escalation state machine: no answer / no ack within a configurable window → next director in the roster → chain exhausted → fall back to the existing SMS/email status quo + surface a failed-ack state in the "Needs a director" queue.
- A NEW
at_need_alert_callslog table (no FK to churches — thecrisis_eventstenant_id+verticalshape) recording every attempt, and dashboard surfacing so no failure is silent. - Per-tenant opt-in (default OFF), a global env kill switch, and a dial-allowlist (roster / notification numbers only; never the family's number except via an explicit director-initiated DTMF-3 bridge).
Explicitly OUT of scope / HARD BOUNDARIES:
- NO changes to
voice-agent-livekit/, the LiveKit SIP trunkST_Xa3Bp9aixRFP, or any LiveKit dispatch rule. Telnyx Call Control is a separate API surface from SIP trunking; this feature places a fresh outbound Call Control call and never touches the inbound voice pipeline. Therefore there is no LIFE-SAFETY voice deploy and novoice-agent-engineerdeploy gate for the call path itself. (The trigger fires off the DB row the voice agent already writes — the voice agent's code is unmodified.) - No email is sent by this feature to families or prospects; the only outbound is an operational phone call (and, on full-chain failure, the existing SMS/email status-quo notification — not a new send path).
- Not a replacement for the existing notification system — it is a faster, higher-urgency layer in front of it that falls back to it.
- No quiet-hours suppression by default (at-need is 24/7); per-tenant configurable.
Build posture (do-not-reinvent): the trigger source
(voice_callback_requests, urgency='at_need') is already written by both the
voice agent and the chatbot today — this feature adds a trigger + endpoint + Telnyx
client + log table + dashboard surfacing, and changes nothing in the capture
path.
1. Verified ground truth (2026-07-02)
Confirmed by querying the live DB (wrwkszmobuhvcfjipasi) before writing:
voice_callback_requestscolumns:id, church_id, caller_name, caller_phone, reason, preferred_time, urgency, status, created_at, agreed_day, agreed_time_window, source, is_hidden, admin_notes, updated_at, caller_email, vertical. The tenant id lives inchurch_id(overloaded to hold any tenant id — same pattern the safety-logging design documents), andverticalexists.sourcedistinguishesvoicevschat(so the trigger covers both channels from one row type).urgencyistext; the valueat_needis already in live use (present values:at_need, low, medium, normal, pastoral_emergency, pre_planning, urgent). Notepastoral_emergencyalso exists — the church cross-vertical hook (§9).funeral_homes.metadatais JSONB andmetadata.staffis a JSON array — the on-call roster source.premium_funeral_homeshascontact_phone(the tenant notification number) andmetadata(opt-in config target).crisis_eventsalready ships the no-FK tenant shape (tenant_id,vertical, plus per-event status/observability columns) — the model forat_need_alert_calls.at_need_alert_callsdoes NOT yet exist (new table).pg_netis AVAILABLE but NOT installed (onlyhttp1.6 andpg_cron1.6.4 are installed). Enablingpg_netis a build prerequisite (§2, open item #1).- Moelker demo: token
demo-moelker-7f3a→funeral_home_ide0f4a11e-f00e-4a11-b000-000000000001,contact_phone226-883-0526, planfwa_demo,metadata.staffpresent (array). This is the only launch- enabled tenant (§8).
2. Trigger → endpoint (covers voice + chatbot, zero voice-agent change)
Should see:
- A Supabase trigger on
AFTER INSERT ON voice_callback_requeststhat fires only whenNEW.urgency = 'at_need'andNEW.vertical = 'funeral', and makes an async HTTP POST (viapg_net) to/api/funeral/at-need/alert-callwith the new row's id + a shared internal secret header. (Because it keys off the row, it covers both capture channels —source='voice'andsource='chat'— with no change to the voice agent or chatbot capture code.) - The endpoint validates the shared secret first (constant-time compare; reject with 401 otherwise) and then re-loads the callback row + the tenant's config + roster server-side from the DB using the row id. It never trusts phone numbers or tenant ids from the POST body — dial targets are derived server-side from the tenant's roster (§7 security; mirrors SECURITY P0 #1102's "don't trust client- supplied identity").
- The endpoint checks, in order: global kill switch (
AT_NEED_ALERT_CALLS_ENABLED) → per-tenant opt-in → quiet-hours (if configured) → dial. If any gate is off, it no-ops gracefully (logs a skipped attempt with the reason; falls through to the existing SMS/email status quo) and never calls.
Should NOT see:
- Any modification to
voice-agent-livekit/, the SIP trunkST_Xa3Bp9aixRFP, or a LiveKit dispatch rule (§0 hard boundary; verify viagit diff --name-only). - The endpoint dialing a number taken from the request body, or acting on an unauthenticated request.
- The trigger firing for non-
at_needrows, non-funeral verticals, or on UPDATE.
Build prerequisite: enable the pg_net extension (founder-gated extension
install). If pg_net is declined, the fallback is the already-installed http
extension (synchronous — must be wrapped so a slow/failed POST cannot block the
INSERT) or a pg_cron drain of a queue row; pg_net (async, non-blocking) is
the recommended and founder-specified path.
3. The call + TTS summary script
Should see:
- A Telnyx Call Control outbound call to the current on-call director's number (from the roster/notification allowlist, §7). Telnyx is already the funeral telephony provider; Call Control is a distinct API surface from the inbound SIP trunk (no trunk/dispatch involvement).
- On answer, Telnyx
speak(TTS) reads a warm, brief (~25s) summary built by a dedicated script-builder from the callback row: an at-need acknowledgment, the caller name, time of the call (tenant-local), and a short reason summary — no jargon, calm and clear, e.g. "This is Moelker Family Funeral Home's answering service with an at-need call. A family member, {caller_name}, called at {local_time} — {reason_summary}. Press 1 to acknowledge, 2 to hear this again, or 3 to be connected to the family now." - DTMF handling: 1 = acknowledge (records who acked, ends the escalation), 2 = repeat the summary, 3 = bridge/transfer the director to the family's number (the only path that ever dials the family — director-initiated, live).
- Answering-machine detection (AMD): if a machine/voicemail is detected, leave the TTS summary as a voicemail and treat the attempt as NOT acknowledged (it still escalates per §4).
Should NOT see:
- The script reading raw/verbose DB fields, PII beyond what the director needs, or
anything that reads as an upsell / sales tone (this is grief-adjacent —
funeralwiseai-vertical.md). - A voicemail counted as an ack; the family's number dialed by anything other than a director pressing 3 on an answered call.
4. Escalation state machine
Should see (verify behavior, not DOM/code presence — sample at ≥2 timepoints):
- State per alert:
initiated → dialing → {answered→acked | answered→no_ack | no_answer | machine→voicemail} → escalating → {acked | exhausted→fallback}. - Ack window: if the current director does not answer, or answers but does
not ack within a configurable window (default ~45–60s), advance to the
next director in the roster (
funeral_homes.metadata.staff, ordered by the tenant's on-call config). - Chain exhausted: when every roster entry has been tried without an ack, fall back to the existing SMS/email status quo notification and set a failed- ack state that surfaces in the funeral admin "Needs a director" queue (§5).
- First ack wins: the moment any director acks (DTMF-1), escalation stops and no further directors are dialed.
- Every transition is logged to
at_need_alert_calls(§6) with a timestamp.
Should NOT see:
- An escalation that never terminates (must exhaust the chain and fall back).
- Two directors bridged to the family at once; a fallback that fires while an ack is still pending inside the window; a silent dead-end with no failed-ack surfaced.
Configurable (per tenant, sensible defaults): ack window seconds, roster order, max attempts per director, whether to loop the roster once or stop after one pass.
5. Dashboard surfacing (honest, never silent)
Ties into the command-center At-Need screen (funeral-dashboard-command-center.md
§5.1):
Should see:
- On the at-need item, an alert-call status that is honest and specific: called (which number/director, when), acknowledged by {director name} (when), escalated (how many directors tried), or failed — no ack (chain exhausted, fell back to SMS/email).
- Status conveyed by text + icon, never color alone (AODA — command-center §9); a failed-ack at-need item is visibly flagged in the "Needs a director" queue.
- A director can see the attempt timeline (each dial, its outcome, the ack) from the at-need drawer.
Should NOT see:
- A fabricated "notified" state; a failure that does not appear anywhere (failures are never silent — honest-metrics contract, command-center §6).
6. at_need_alert_calls log table (NEW — no FK, crisis_events shape)
Should see:
- A new table modeled on
crisis_events(products/funeralwiseai/safety-logging-design.md): a plaintenant_id uuid(NO foreign key) +vertical text, so a funeral (or later church/vet/realtor) tenant id is never schema-blocked. Do NOT add any FK tochurches/organizations(that is exactly the defect the safety-logging design retires). - Columns (proposed — confirm in Stage-2):
id,tenant_id(no FK),vertical,callback_request_id(the sourcevoice_callback_requests.id),source(voice|chat),director_name,director_phone,attempt_number,telnyx_call_control_id,outcome(acked|no_answer|no_ack|voicemail|bridged|skipped|failed),dtmf_pressed,acked_at,escalated_to_next_at,voicemail_left,error,created_at,updated_at. - Written by the endpoint / escalation state machine (service role), one row per
attempt/transition; RLS consistent with the other tenant-scoped tables
(service-role writes; per-tenant reads scoped by
tenant_id).
Should NOT see:
- An FK on
tenant_id; PII beyond operational need stored in the clear (redact/omit the family's details beyond name + reason summary if the founder prefers — Stage-2 open item #5).
7. Config & safety controls
Should see:
- Per-tenant opt-in, default OFF for every tenant (including Kolssak
demo-kolssak-51cb— untouched, never dialed). Stored onpremium_funeral_homes.metadata(e.g.metadata.at_need_alert_calls.enabled). - Global env kill switch
AT_NEED_ALERT_CALLS_ENABLED— off ⇒ the endpoint no-ops for all tenants (belt over the per-tenant switch). - Launch state: the Moelker demo
demo-moelker-7f3a(tenante0f4a11e-…-000000000001) is the ONLY tenant enabled at launch, dialing the founder's cell226-883-0526(the demo's real on-call director). - Dial allowlist (hard): the system may ONLY dial numbers from the tenant's
configured roster (
funeral_homes.metadata.staff) / notification number (premium_funeral_homes.contact_phone). It NEVER dials the family's number — the sole exception is an explicit DTMF-3 bridge initiated by an already-answered director. The allowlist is enforced server-side from the DB, never from the request body. - Quiet hours: none by default (at-need is 24/7); per-tenant configurable if a tenant wants them.
- CASL: this is a transactional operational alert to the account's OWN staff (the funeral home's own on-call directors) — no CASL implications (not marketing, not to the public). The spec states this explicitly so no agent adds a consent gate that would delay an at-need alert.
Should NOT see:
- Any tenant other than Moelker enabled at launch; any path that dials the family outside the director-initiated DTMF-3 bridge; a dial target sourced from the POST body; Kolssak dialed or referenced.
8. Church / vet / realtor regression gate
- RG-1 — For a church or vet tenant, the feature is entirely absent:
the trigger does not fire (vertical guard + opt-in default OFF), no call is placed,
and no
at_need_alert_callsrows appear. Verify a church demo callback insert produces zero alert-call activity. - RG-2 — The capture path (
voice_callback_requestsINSERT) behaves exactly as before for every vertical; the trigger is additive and gated. - RG-3 — No change to
voice-agent-livekit/,moderation.py, the SIP trunk, or dispatch rules (diff-verified) — church/vet/realtor voice lines are provably untouched.
9. Cross-vertical design note (funeral is the first consumer)
The core — trigger → outbound call → ack → escalate → log — must be designed vertical-generic so other verticals can adopt it later without a rewrite:
- Church — a
pastoral_emergencycallback (the value already exists invoice_callback_requests.urgency) alerting an on-call pastor. - Vet — an after-hours animal emergency alerting the on-call vet.
- Realtor — a hot-lead "call me now" alerting the on-call agent (speed-to-lead).
Requirements for genericity: key the trigger/endpoint on (vertical, urgency)
pairs (config-driven, not funeral-hardcoded); source the roster + opt-in from the
per-vertical tenant tables via a small adapter; keep at_need_alert_calls
tenant-generic (tenant_id + vertical) so it serves all consumers. Funeral is
the first and only wired consumer in this MVP — build the seam, don't build the
other verticals yet.
10. Verification (per the Verification Ladder)
Behavioral verification; evidence attached; never "build passes".
Unit / integration:
- Escalation state machine — table-driven tests over every path (answer→ack, answer→no-ack→next, no-answer→next, machine→voicemail→next, chain-exhausted→ fallback, first-ack-wins-stops-chain, kill-switch/opt-in/quiet-hours no-op).
- Script builder — produces a warm ~25s summary from a callback row; includes caller name / local time / reason; no PII overreach; deterministic; handles missing fields gracefully.
- Dial allowlist — a target not in the tenant roster/notification set is rejected; a POST-body-supplied number is ignored.
- Auth — the endpoint 401s without the shared secret.
End-to-end (the real test):
- A REAL outbound call to the founder's cell
226-883-0526via the Moelker demo (demo-moelker-7f3a) — insert an at-needvoice_callback_requestsrow for the Moelker tenant and confirm: the phone rings, the ~25s summary reads, DTMF-1 acks (verifyat_need_alert_callsrecords the ack + the at-need item shows "acknowledged"), no-ack escalates to the next roster entry, and chain exhaustion falls back to SMS/email + a failed-ack flag. (Founder is the demo's on-call director, so this dials only the founder — no third party involved.)
Regression:
- Church + vet callback insert ⇒ feature absent (§8).
git diff --name-onlyshows nothing undervoice-agent-livekit/or touching the SIP trunk / dispatch rules.
Observability:
- Every attempt visible in the dashboard; no silent failure (§5).
// state machine
test.fixme('answer+DTMF1 => acked, escalation stops, at_need_alert_calls records ack + director', () => {});
test.fixme('no answer within window => dials next roster director', () => {});
test.fixme('answering machine => leaves voicemail, does NOT count as ack, escalates', () => {});
test.fixme('chain exhausted => falls back to SMS/email status quo + failed-ack in Needs-a-director queue', () => {});
test.fixme('first ack wins: a later director is never dialed once one acks', () => {});
// gates + security
test.fixme('global kill switch off => endpoint no-ops for all tenants', () => {});
test.fixme('tenant opt-in default OFF => no call (incl. Kolssak)', () => {});
test.fixme('endpoint 401s without the shared secret; ignores body-supplied phone/tenant', () => {});
test.fixme('dial allowlist: only roster/notification numbers; family number only via director DTMF-3 bridge', () => {});
// boundary + regression
test.fixme('git diff touches nothing under voice-agent-livekit/ or the SIP trunk/dispatch rules', () => {});
test.fixme('church/vet at-need-equivalent insert => feature entirely absent', () => {});
// e2e (Moelker demo, real call to 226-883-0526)
test.fixme('at_need insert for Moelker demo => phone rings, ~25s summary, DTMF-1 ack recorded + surfaced', () => {});
11. Open items (Stage-2 founder validation)
| # | Question | Blocks | Priority |
|---|---|---|---|
| 1 | Enable pg_net? It is available but not installed. Confirm enabling it (recommended, async) vs. the http-extension or pg_cron-drain fallback. | §2 | P0 |
| 2 | On-call roster shape — confirm the exact funeral_homes.metadata.staff structure (fields: name, phone, on-call order/flag) and how "current on-call" is chosen (rotation? all-in-order?). | §4, §7 | P0 |
| 3 | Ack window + roster behavior defaults — confirm ~45–60s, one pass vs. loop, max attempts per director. | §4 | P1 |
| 4 | Telnyx Call Control config — which Telnyx number/connection places the outbound call; AMD settings; TTS voice/locale for speak. | §3 | P1 |
| 5 | PII in at_need_alert_calls — store caller name + reason summary, or redact to a minimum? Retention period? | §6 | P1 |
| 6 | at_need_alert_calls columns + RLS — confirm the proposed columns and the per-tenant read policy. | §6 | P1 |
| 7 | Fallback definition — confirm the exact existing SMS/email "status quo" path the chain falls back to (so we call it, not reinvent it). | §4 | P1 |
| 8 | Bridge (DTMF-3) semantics — connect director↔family live; what happens if the family's line is busy/no-answer? | §3 | P2 |
| 9 | Cross-vertical seam now vs. later — build the (vertical, urgency) config seam in this MVP (funeral-only wired) or defer? | §9 | P2 |
12. Guardrails for agents building against this spec
- Rule #17 not satisfied — do not build until founder approval.
- HARD BOUNDARY — do NOT touch
voice-agent-livekit/, the SIP trunkST_Xa3Bp9aixRFP, or any dispatch rule. Telnyx Call Control ≠ SIP trunking; there is no LIFE-SAFETY voice deploy in this feature. Diff-verify the boundary. - Trust the secret, not the body — validate the shared internal secret; derive every dial target server-side from the tenant's roster; never dial a body-supplied number (SECURITY P0 #1102 posture).
- Dial allowlist is absolute — roster/notification numbers only; the family's number is dialed ONLY via a director-initiated DTMF-3 bridge.
- Default OFF everywhere — per-tenant opt-in default OFF + global env kill switch; Moelker demo is the only launch-enabled tenant; Kolssak never dialed or referenced.
- No FK on
at_need_alert_calls.tenant_id— reuse thecrisis_eventsno-FKtenant_id+verticalshape (safety-logging-design.md). - Verify DB columns before use (Rule #18) — the §1 facts were verified; re-verify
metadata.staffstructure before coding against it. - No new email/SMS send path — the fallback uses the EXISTING notification path; the only new outbound is the operational phone call. No cron may originate a campaign send (portfolio P0).
- Honest observability — every attempt in the dashboard; no silent failure.
- Verify on the real host / real call — the e2e is a real call to the founder's cell via the Moelker demo; evidence attached.
- If code diverges, update this spec first (founder approval), then the code.
End of spec. STATUS: DRAFT — NOT APPROVED.