Voice Pipeline — Complete Call Lifecycle
This document is the definitive source of truth for the ChurchWiseAI voice agent pipeline. No agent should touch voice code without reading this first. Every step from SIP arrival to call termination is traced with file references, input/output, third-party calls, database operations, and regression risks.
Top-Level Architecture Flowchart
File Index
| File | Role |
|---|---|
main.py | Entrypoint, session wiring, agent path builders |
session.py | PHONE_REGISTRY, routing, Supabase singleton, call log lifecycle, classify_call, caching, all loaders |
safety.py | SafeAgent base class — pre-LLM safety layer (threat/crisis/abuse/noise/per-turn RAG) |
call_handler.py | Noise filtering, mutual farewell detection, "are you there?" detection |
moderation.py | Regex patterns for threat, crisis, abuse detection |
core/rag.py | OpenAI embedding generation, Supabase RPC search, context formatting |
core/tools.py | end_call_gracefully, _send_sms_link, _send_directions_link |
core/notifications.py | Resend email + Twilio SMS, all notification types, QA test redirect |
core/prompt_fragments.py | CRISIS_PROTOCOL, HEAR_PROTOCOL, NATURAL_SPEECH, all 14 shared prompt fragments |
verticals/church/agents.py | CoordinatorAgent, CareAgent — all @function_tool methods |
verticals/church/prompts.py | build_coordinator_prompt(), build_care_prompt() — full prompt builders |
verticals/church/tools.py | DB write implementations: prayer, callback, visitor, event |
verticals/church/config.py | Default voice IDs, TIER_AGENTS, get_opposite_voice() |
verticals/church/tradition_care_contexts.py | 17 tradition contexts for Care Agent calibration |
verticals/church/integrations/supabase_church.py | load_church_data() — 3 DB queries + cache |
verticals/church/integrations/cal.py | Cal.com availability + booking API |
verticals/church/integrations/planning_center.py | Planning Center services, events, staff directory |
verticals/sales/agents.py | SalesAgent, DemoRouterAgent, DemoAgent |
verticals/sales/prompts.py | build_sales_prompt(), build_demo_prompt() |
Call Lifecycle — Step by Step
Step 1: SIP Call Arrives at LiveKit Cloud
File: main.py:69–83
Input: A SIP INVITE from Telnyx (new customers) or Twilio (legacy numbers). The call arrives at the LiveKit SIP endpoint cwa-voice-9x077mph.sip.livekit.cloud.
Logic:
- Telnyx routes directly via SIP INVITE to the LiveKit SIP endpoint.
- Twilio routes via SIP trunk
ST_Xa3Bp9aixRFP(PROTECTED — DO NOT MODIFY) with numbers+18886030316,+14696152221,+13658254095,+14144007103. - LiveKit Cloud receives the INVITE and creates a room.
- LiveKit Dispatch Rules
SDR_cYzx7sAkUTvxandSDR_Wpyno7GDNQqgmatchagent_name="churchwiseai-voice"and dispatch a job to the worker pool.
Output: A JobContext is delivered to the Python worker process via the @server.rtc_session handler. The room exists but the agent is not yet connected.
Third-party calls: LiveKit Cloud (SIP gateway → room creation → job dispatch)
Expected result: The agent process receives a JobContext with the room name. Connection to the room begins.
HEAR impact: No impact yet — call not answered.
Regression risk: If the dispatch rule agent_name changes, no jobs arrive. If trunk numbers change (e.g., + prefix removed), Twilio calls fail silently. The main trunk ST_Xa3Bp9aixRFP is LOCKED — never modify it.
Step 2: AgentServer Pre-Warm (Process Startup, Not Per-Call)
File: main.py:77–83
Input: Python worker process startup.
Logic:
def prewarm(proc: JobProcess):
proc.userdata["vad"] = silero.VAD.load()
Silero VAD model is loaded ONCE per worker process, then reused for every call on that worker. This avoids the 2–4s model load time per call.
Output: proc.userdata["vad"] contains a loaded Silero VAD model.
Third-party calls: None (local model load).
Expected result: VAD model ready. Every call on this worker will use ctx.proc.userdata["vad"].
Regression risk: If prewarm is removed or the key name changes, every call either re-loads the VAD model (2–4s latency spike) or crashes with a KeyError.
Step 3: Entrypoint — Room Connection and Participant Wait
File: main.py:128–173
Input: JobContext ctx delivered by LiveKit dispatcher.
Logic:
await ctx.connect()— Agent connects to the LiveKit room. MUST happen beforewait_for_participant(LiveKit/agents#4861).await ctx.wait_for_participant()— Waits for the SIP participant (the caller) to join.- Extract SIP attributes from
participant.attributes:sip.phoneNumber→caller_phone(the caller's number, e.g.+16155551234)sip.trunkPhoneNumber→dialed_number(the church's number, e.g.+14144007103)sip.callID→call_id(LiveKit call UUID, used ascall_sidin DB)
- Normalize: Telnyx may omit
+prefix — add it if missing. - Call
_run_call(). Any exception →_speak_error_and_hangup().
Output: caller_phone, dialed_number, call_id available for routing.
Third-party calls: LiveKit Cloud (room connect, participant wait).
Expected result: The caller's phone number and the dialed church number are known. Routing can proceed.
HEAR impact: No impact yet. Caller has not been greeted.
Regression risk: If sip.trunkPhoneNumber attribute name changes in a LiveKit SDK update, dialed_number will be empty string and ALL calls will fall through to Sales Agent fallback.
Step 4: Route Resolution
File: session.py:147–171 (resolve_route)
Input: dialed_number (E.164 string, e.g. +14144007103)
Logic (in order):
- Check
SALES_NUMBERSset — if match, return("sales", None) - Check
DEMO_NUMBERSset — if match, return("demo_router", None) - Check
PHONE_REGISTRYdict — if match, return("church", church_id)(or("church", None)for unassigned) - Unknown number — return
("church", None)for DB lookup
Current PHONE_REGISTRY entries:
PHONE_REGISTRY = {
"+13658253552": None, # Spare, unassigned
"+14144007103": "96f5b89e-b238-4811-8d76-...", # Medhanialem Ethiopian Evangelical Church (Telnyx)
}
Current SALES_NUMBERS: +18886030316 (toll-free), +19472254895 (Cartesia agent sales line)
Current DEMO_NUMBERS: +14696152221 (US demo Twilio), +13658254095 (CA demo Twilio), +13186678328 (Cartesia demo forward)
Output: (agent_type: str, church_id: str | None) tuple.
Third-party calls: None (pure dict lookup).
Expected result: Every known number immediately resolves without a DB call. Unknown numbers fall through to DB lookup in Step 6.
HEAR impact: None.
Regression risk: If a new Telnyx number is provisioned but not added to PHONE_REGISTRY, ALL calls to it will trigger a DB lookup (slow) and potentially fall back to Sales Agent if the DB lookup fails. New numbers MUST be added to PHONE_REGISTRY during provisioning.
Step 5: Supabase Client Initialization
File: session.py:118–140 (get_supabase)
Input: Environment variables SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY.
Logic:
- Singleton pattern — initialized once per process, reused for all calls.
- Uses
supabase.acreate_client()(async client). - Warns if env vars missing (does not raise — call proceeds with broken client).
Output: _supabase async client instance.
Third-party calls: Supabase (client initialization only — no queries yet).
Expected result: Async Supabase client ready. All subsequent DB calls use this singleton.
Regression risk: If SUPABASE_SERVICE_ROLE_KEY rotates and the env var is not updated in LiveKit Cloud secrets, every DB call will fail with auth errors. The agent will fall back to Sales for all calls.
Step 6: QA Testing Mode Check
File: main.py:185–189, core/notifications.py:33–48
Input: supabase, church_id
Logic:
- Queries
qa_testing_configtable for(church_id)→{tester_phone, tester_email} - If found, sets
_current_redirectglobal innotifications.py - All subsequent email/SMS for this call will be redirected to tester instead of church staff
- Subject lines prefixed with
[TEST], SMS bodies prefixed with[TEST for {original_to}]
Database operations: qa_testing_config table — SELECT tester_phone, tester_email WHERE church_id = ?
Expected result: In production calls, no redirect is set. During QA testing, notifications go to tester.
Regression risk: If qa_testing_config table has a stale row for a real church, ALL real calls to that church will send notifications to the tester instead of church staff. Always clean up QA records after testing.
Step 7: Church Data Loading
File: verticals/church/integrations/supabase_church.py:31–218 (load_church_data)
Input: supabase, church_id
Logic — Cache-First:
- Check in-memory cache key
church:{church_id}(TTL: 60 seconds) - If cache miss: run 3 Supabase queries in sequence:
_fetch_voice_agent_row()—church_voice_agents+churches+premium_churchesJOIN_fetch_agent_config()—organization_settingsfor agent personality overrides_fetch_premium_tier()—premium_churchesfor plan and subscription status
- Enforce subscription: if
statusnot in("active", "unknown")→ returnNone(call rejected) - Enforce call limit: if
calls_this_month >= calls_limit→ returnNone(call rejected) - Assemble result dict with 40+ fields (see below)
- Cache result for 60 seconds
- On any Supabase error: serve stale cache if available; else return
None
Database queries:
Query 1 — church_voice_agents (with joins):
SELECT *, churches.*, premium_churches.*
FROM church_voice_agents
LEFT JOIN churches ON churches.id = church_voice_agents.church_id
LEFT JOIN premium_churches ON ...
WHERE church_voice_agents.church_id = ?
Returns: voice_id, welcome_greeting, notification_email, notification_phone, pastor_name, pastor_availability, church_timezone, recording_enabled, prayer_requests_enabled, visitor_intake_enabled, callback_scheduling_enabled, cal_event_type_id, cal_api_key, pco_enabled, pco_app_id, pco_secret, giving_enabled, giving_url, etransfer_email, giving_message, human_request_message, crisis_message, sermon_topic, sermon_series, theme_verse, weekly_announcement, custom_faqs, calls_this_month, calls_limit, status; plus churches.{name, address, phone, website, denomination, working_hours}; plus premium_churches.{custom_name, custom_hours, custom_staff, custom_ministries, what_to_expect, events, sermons}
Query 2 — organization_settings:
SELECT agent_config FROM organization_settings WHERE organization_id = ? LIMIT 1
Query 3 — premium_churches:
SELECT plan, status FROM premium_churches WHERE church_id = ? LIMIT 1
Output: church dict with 40+ keys, or None (rejected). If None, _build_church_path() falls back to _build_sales_path().
Expected result: Full church config loaded. Subscription and call limit enforced. Cache prevents repeated DB hits during concurrent calls.
HEAR impact: Tradition, pastor name, welcome greeting, and agent personality overrides from this dict all directly shape what the agent says. Wrong data = wrong persona.
Regression risk:
- Any new column used in agent code that does not exist in the DB will silently return
Nonefrom the dict. ALWAYS verify columns viainformation_schemabefore using them. - 60s cache means cancellations take up to 60s to propagate.
- Stale-serve fallback means a cancelled church can still get calls served if Supabase goes down after their cancellation.
Step 8: Call Log Insert
File: session.py:178–218 (insert_call_log)
Input: supabase, call_id (LiveKit call UUID), church_id, caller_phone, to_number
Logic:
- Inserts initial
voice_call_logsrecord withstatus = "in_progress" - Retries once after 0.5s on failure
- Non-fatal: call proceeds even if insert fails
Database operations:
INSERT INTO voice_call_logs (call_sid, church_id, from_number, to_number, status)
VALUES (?, ?, ?, ?, 'in_progress')
Column mapping note: call_sid = LiveKit call UUID (NOT Twilio SID — historical naming confusion). from_number = caller's number. to_number = church's number.
Expected result: A voice_call_logs row exists from call start, visible in admin dashboard as in-progress.
Regression risk: If column names change in the DB, this insert fails silently. The call proceeds but leaves no DB record. The _finalize_call update also has no row to update.
Step 9: Call Count Increment
File: session.py:801–824 (increment_call_count)
Input: supabase, church_id
Logic:
- Calls Supabase RPC
increment_voice_call_count(atomic increment, avoids race conditions) - Non-fatal: if it fails, the church gets a free call rather than a dropped call
Database operations:
-- RPC: increment_voice_call_count(p_church_id)
-- Atomically: church_voice_agents.calls_this_month += 1 WHERE church_id = p_church_id
Expected result: Call count incremented before the conversation starts. Used for plan enforcement on subsequent calls.
Regression risk: If the RPC is dropped or renamed, calls won't be counted and churches can exceed plan limits without enforcement.
Step 10: Parallel Context Loading (5 Async Tasks)
File: main.py:503–515
Input: supabase, church_id, denomination, church_name, caller_phone
Logic: asyncio.gather() runs all five loaders in parallel:
10a. fetch_session_rag (core/rag.py:275–323)
- Generates embedding for seed query
"Tell me about {church_name}, services, events, programs, ministries"via OpenAItext-embedding-3-small(1536 dimensions) - Runs two Supabase RPC searches in parallel:
search_church_knowledge(church_id, embedding, match_count=8, match_threshold=0.35)— church-specific KBsearch_unified_rag_content(embedding, lens_ids=[get_lens_id(denomination)], match_count=5, match_threshold=0.35)— theological content
- Returns formatted string with
--- Church Knowledge Base ---and--- Curated Theological Content ---blocks - Each result truncated to 600 characters
Theological lens mapping (17 denominations → lens IDs):
- Baptist/SBC → 14, Catholic → 7, Methodist/Nazarene → 5, Reformed/Presbyterian → 4, Lutheran → 6, Pentecostal/AoG → 9, Anglican/Episcopal → 13, Orthodox → 11, Anabaptist → 8, Non-denominational → 10, Charismatic → 15, Evangelical/CoC → 1, Black Church → 12, Dispensational → 16, Progressive/UCC → 2, Missional → 3, Liberation → 17, Unknown → 10
10b. load_product_knowledge (session.py:917–952)
- Queries
product_knowledgetable:SELECT category, question, answer WHERE is_active=true ORDER BY priority DESC - Formats as
PRODUCT KNOWLEDGE:\nQ: ...\nA: ...block - Cached 15 minutes (key:
pk:all)
10c. load_inline_faqs (session.py:959–998)
- Queries
church_knowledge_baseWHEREorganization_id = church_id AND question IS NOT NULL - Formats as
CHURCH FAQ:\nQ: ...\nA: ...block - Cached 5 minutes (key:
faq:{church_id})
10d. load_repeat_caller_history (session.py:865–910)
- Queries
voice_call_logsWHEREfrom_number = caller_phone AND church_id = ? AND created_at >= 90_days_agoORDER BYcreated_at DESCLIMIT 5 - Returns
RETURNING CALLER HISTORY (PRIVACY-GATED):\nThe caller has called before. Previous topics: ...\nDo NOT mention these unless the caller brings them up first. - Empty string if no prior calls
10e. load_tradition_care_context (session.py:1005–1071)
- Maps denomination to
tradition_key(e.g., "Southern Baptist" → "southern_baptist") - Queries
tradition_care_contextWHEREtradition_key = ? AND is_active = trueLIMIT 1 - Falls back to Python module
tradition_care_contexts.pyif DB fails - Cached 15 minutes (key:
tradition:{tradition_key}) - Returns 200–400 word care guidance block including
TRADITION-SPECIFIC AVOIDS:section
Output: 5-tuple: (rag_context, product_knowledge, inline_faqs, repeat_history, tradition_context)
All 5 strings are concatenated with "\n\n".join(filter(None, [...])) plus a build_datetime_context() result, forming full_rag_context.
10f. build_datetime_context (session.py:1078–end)
- Pure function — no DB call
- Converts current UTC time to church local timezone (via
ZoneInfo) - Returns block with date, time, day name, relative references ("This Sunday", "Next week")
Third-party calls: OpenAI Embeddings API (text-embedding-3-small), Supabase RPCs (search_church_knowledge, search_unified_rag_content), Supabase tables (product_knowledge, church_knowledge_base, voice_call_logs, tradition_care_context)
Expected result: All 5 loaders complete in parallel. Full context assembled for system prompt injection. Typical latency: 200–800ms (dominated by OpenAI embedding).
HEAR impact: The RAG context, tradition context, and repeat caller history directly shape how the agent responds to this specific caller at this specific church. Missing or wrong tradition context = wrong pastoral language.
Regression risk:
- If
OPENAI_API_KEYis missing, RAG is silently disabled (empty string). Agent has no church KB context. - If
SUPABASE_SERVICE_ROLE_KEYis wrong, all 5 loaders fail and the agent has no product knowledge, FAQs, or tradition context. - Inline FAQs and product knowledge are separate from vector RAG — both must work for full coverage.
Step 11: CoordinatorAgent Construction
File: verticals/church/agents.py:219–237, verticals/church/prompts.py:241–447
Input: church dict, full_rag_context string, voice_id string, tradition_context string
Logic:
- Call
build_coordinator_prompt(church)— pure function, returns system prompt string - If
tradition_contextcontainsTRADITION-SPECIFIC AVOIDS:, extract and append to instructions - Append
full_rag_contextas--- KNOWLEDGE BASE ---block - Construct
CoordinatorAgent(instructions=..., llm=_coordinator_llm())
LLM configuration:
def _coordinator_llm():
return llm.FallbackAdapter([
google.LLM(model="gemini-2.5-flash"), # Primary
anthropic.LLM(model="claude-haiku-4-5-20251001"), # Fallback
])
System prompt structure (in order):
- Identity: "You are the AI receptionist for {name}. You are calm, warm, and welcoming."
NATURAL_SPEECH— filler phrases, context-matching rulesHEAR_PROTOCOL— 4-step empathy framework (Hear, Empathize, Advance, Respond)build_shared_fragments(church_name, pastor_name)— all 14 safety/behavioral fragments:- CRISIS_PROTOCOL (suicide/self-harm → 988 immediately)
- DV_HOTLINES (domestic violence resources)
- MEDICAL_LEGAL_GUARDRAILS
- AI_DISCLOSURE
- HONESTY_RULE (never say "I'll pray")
- SIGN_OFF_RULES (always faith-encouraging)
- CLEAN_ENDINGS (one "anything else?", proactive close)
- FORMATTING_RULES (1–2 sentences, no markdown, spell out numbers)
- WRONG_NUMBER
- ABUSE_HANDLING (2-strike policy)
- STT_ERROR_TOLERANCE (name recognition, transcription errors)
- OTHER_CHURCHES (redirect to PewSearch)
- SCOPE_ENFORCEMENT (stay on mission)
- CRITICAL_SAFETY (never position as only support)
- Church facts (address, denomination, phone, website, hours, staff, ministries, what_to_expect, events)
- Custom FAQs (if configured)
- Pastor's Pulse (sermon topic, series, theme verse, weekly announcement)
- TOPIC AWARENESS block — what to handle directly vs. hand off
- GIVING & STEWARDSHIP block — zero-pressure giving guidance
- DEMANDING A REAL PERSON protocol
- LANGUAGE block (Spanish/multilingual — tool data always in English)
- Personality overrides from
agent_config.coordinator.personalityOverrides --- KNOWLEDGE BASE ---(RAG + product knowledge + FAQs + repeat history + datetime)- Tradition-specific avoids (extracted from tradition_context)
Output: CoordinatorAgent instance with full system prompt, Gemini/Haiku LLM fallback chain.
Expected result: Agent ready to answer this specific church's calls with the right persona, facts, and knowledge.
HEAR impact: HEAR_PROTOCOL is section 3 of the prompt. NATURAL_SPEECH controls filler phrases. TOPIC AWARENESS controls when to empathize first vs. handle directly. All directly impact call quality.
Regression risk:
- Prompt section ordering matters. Safety fragments MUST come before topic-specific instructions. CRISIS_PROTOCOL overrides topic awareness if both match.
- If
church.get("agent_config")returns malformed JSON, personality overrides are silently skipped (empty string fallback in_build_personality_overrides). FORMATTING_RULESenforces no markdown — if removed, TTS will speak asterisks and bullet characters aloud.
Step 12: Voice Resolution and TTS Setup
File: main.py:220–240
Input: church_data.get("voice_id", ""), verticals/church/config.py
Logic:
"random" → pick randomly from [DEFAULT_VOICE_ID_MALE, DEFAULT_VOICE_ID_FEMALE]
"" → Cartesia default voice
any other → use as Cartesia voice ID
Default voice IDs (Cartesia stock, verified 2026-03-27):
- Male:
86e30c1d-714b-4074-a1f2-1cb6b552fb49(Carson - Curious Conversationalist) - Female:
1242fb95-7ddd-44ac-8a05-9e8a22a6137d(Cindy - Receptionist)
Resolved voice_id is written to agent.voice_id so CareAgent can compute opposite gender.
TTS setup:
primary_tts = cartesia.TTS(voice=voice_id) if voice_id else cartesia.TTS()
session_tts = tts.FallbackAdapter([primary_tts, google.TTS()])
STT setup:
session_stt = stt.FallbackAdapter([
deepgram.STT(model="nova-3"),
google.STT(),
])
Session configuration:
AgentSession(
stt=session_stt,
tts=session_tts,
vad=ctx.proc.userdata["vad"], # Silero, pre-warmed
turn_handling=TurnHandlingOptions(
turn_detection=MultilingualModel(),
interruption={"enabled": True, "min_duration": 0.5},
),
max_tool_steps=5,
preemptive_generation=True, # Start LLM before user finishes speaking
)
Third-party calls: Cartesia (TTS), Deepgram (STT), Google Cloud (TTS + STT fallback)
Expected result: TTS uses church-specific voice. If Cartesia is down, Google TTS covers. If Deepgram is down, Google STT covers.
HEAR impact: Voice selection directly shapes empathy perception. Opposite gender for CareAgent signals mode change to the caller. "Random" voice creates consistent experience within a call (resolved once at call start).
Regression risk:
- Cartesia voice IDs must stay valid. If the stock voice IDs are retired, all churches without custom voice_id will use Cartesia's default (wrong voice but functional).
preemptive_generation=Truecan produce slightly wrong responses if STT is still revising. Monitor for mis-fires.
Step 13: Noise Cancellation and Session Start
File: main.py:408–427
Input: session, agent, ctx.room
Logic:
- Wire session safety (
wire_session_safetyfromsafety.py) — hooksuser_input_transcribedfor "are you there?" detection - Set
agent.call_contextdict:{call_id, caller_phone, dialed_number, church_data, supabase} await session.start()with:room_options→noise_cancellation.BVCTelephony()for SIP participants (BVC for non-SIP)- Telephony-optimized noise cancellation removes phone line hiss, DTMF tones, background noise
Third-party calls: LiveKit noise cancellation (BVC/BVCTelephony)
Expected result: Session active. Noise cancellation applied. "Are you there?" handler wired. Call metadata attached to agent for tools.
Regression risk: If call_context is not set before session.start(), ALL tools will fail with KeyError when they try to access call_context["supabase"]. The pattern is: build agent → attach call_context → start session.
Step 14: Initial Greeting (on_enter)
File: verticals/church/agents.py:239–264
Input: self.church dict (name, welcome_greeting)
Logic:
if custom_greeting:
# Insert AI disclosure after first sentence
# "Thank you for calling X. How can I help?" →
# "Thank you for calling X. I'm an AI assistant. How can I help?"
else:
greeting = f"Thank you for calling {church_name}. I'm an AI assistant. How can I help you today?"
- Uses
session.say()withallow_interruptions=False(notgenerate_reply()) - This avoids LLM latency and prevents paraphrasing of the greeting
- AI disclosure is ALWAYS injected — mandatory per product policy
Third-party calls: Cartesia TTS (to speak the greeting), Deepgram STT (listening for response)
Expected result: Caller hears greeting within ~500ms of connection. No LLM round-trip needed. Greeting is deterministic.
HEAR impact: First impression. Warm, calm tone sets the stage for HEAR protocol.
Regression risk:
- If
generate_reply()is used instead ofsession.say(), there's a 1–2s dead air gap before the greeting. - If the
". "split in custom greeting fails (e.g. greeting has no period), AI disclosure is appended at end — less natural but still compliant.
Step 15: Conversation Loop — STT Turn
Architecture: STT → Turn Detector → llm_node (SafeAgent) → LLM → TTS
File: LiveKit Agents pipeline + safety.py:113–232
Input: Caller speech audio (PCM from LiveKit room).
STT Processing:
- Deepgram Nova-3 transcribes in real-time with streaming results
MultilingualModelturn detector decides when the caller has finished speaking (avoids cutting off mid-sentence)- Interruption enabled with
min_duration=0.5s(half-second of new speech cancels pending response) preemptive_generation=Truestarts LLM before utterance is fully complete
Output: Final transcribed text passed to llm_node.
Third-party calls: Deepgram Nova-3 (streaming STT), LiveKit turn detection
Regression risk: If min_duration=0.5 is too short, rapid speech (e.g. "no no no") causes spurious cancellations. If too long, the agent interrupts callers.
Step 16: Pre-LLM Safety Layer (SafeAgent.llm_node)
File: safety.py:113–232 — runs on EVERY caller utterance
Input: chat_ctx (full conversation history), user_text (latest caller utterance)
Logic — 5 checks in order:
Check 1 — Noise Filtering (call_handler.py:57–91)
- If
should_filter(user_text, agent_asked_question)→ return silently, no LLM call - Filters:
um,uh,hmm,uh huh,mm hmm(always filtered) - Context-dependent:
okay,ok,right,good,great— filtered UNLESS agent asked a question - Never filtered:
yes,yeah,no,thanks,bye,sure,please(always meaningful) _agent_asked_questionupdated after each LLM response (True if response contains?)
Check 2 — Threat Detection (moderation.py:32–46, safety.py:152–158)
- Regex: violent threats toward others (kill him/her/you/them, shoot up, bomb the, etc.)
- Excludes self-harm context (
kill myself→ crisis, not threat) - Excludes negated phrases (
I'm not going to hurt anyone) - If detected:
- Log violation to
moderation_violationstable - Fire-and-forget: email to church admin + SMS to church admin + email to CWA support
- Yield
THREAT_RESPONSE = "I need to end this call. If you or someone else is in danger, please call nine one one immediately." - Return (no LLM call)
- Log violation to
Check 3 — Crisis Detection (moderation.py:73–131, safety.py:160–165)
- Regex patterns for suicidal ideation including:
- Direct: "want to die", "kill myself", "end my life"
- Hopelessness: "what's the point", "I'm just a burden", "everyone would be better off"
- C-SSRS Q1: "wish I were dead", "wish I could go to sleep and not wake up"
- Elderly coded: "tired of living", "lived long enough", "ready to go" (excluding benign destinations)
- Religious coded: "ready to meet my maker", "going home to be with the Lord"
- Farewell signals: "giving away my things", "said my goodbyes", "this is my last"
- Stem patterns:
suicid*,self-harm* - Context-aware: "ready to go to church/home/work/bed" excluded from "ready to go" match
- If detected:
- Log violation to
moderation_violationstable - Fire-and-forget: email to church admin (pastoral tone) + email to CWA support
- Yield
CRISIS_RESPONSE = "I hear you. Please call or text nine eight eight right now. They are there for you, twenty four seven. You matter." - Return (no LLM call)
- Log violation to
Check 4 — Abuse Detection (moderation.py:153–230, safety.py:167–181)
- 2-strike system per call session (
self._abuse_session = {"abuse_count": 0}) - Patterns: profanity, hostile phrases, "kill yourself"
- Strike 1 →
"warning"→ yieldABUSE_WARNING→ no LLM call - Strike 2 →
"end_call"→ log violation + notify church admin + yieldABUSE_END_CALL
Check 5 — Per-Turn RAG (core/rag.py:330–378, safety.py:183–213)
- Only for church agents (checks
call_context["church_data"]["church_id"]) - Generates OpenAI embedding for the user's utterance
- Searches
church_knowledge_baseviasearch_church_knowledgeRPC (match_count=5, match_threshold=0.4) - Hard 500ms timeout — if slow, skipped gracefully
- If results: injects
[Relevant church info for this question]\n{rag_context}as system message inchat_ctx - Falls through to normal LLM
Third-party calls (per turn, when applicable): Supabase (moderation_violations INSERT, church KB search RPC), OpenAI (per-turn embedding), Resend API (threat/crisis notifications), Twilio (threat/SMS alert)
Expected result: Every caller utterance passes through safety checks before the LLM sees it. Crisis callers receive 988 immediately. Violent callers receive 911 redirect. Noise is filtered silently. Clean speech flows to LLM with relevant church KB context.
HEAR impact: Crisis path completely bypasses the LLM — the fixed CRISIS_RESPONSE is always calm, grounded, and direct. This is a hard override.
Regression risk: LIFE-SAFETY. Never weaken crisis patterns without clinical review. The _READY_TO_GO_BENIGN exclusion list must be maintained as usage patterns evolve. If the exclusion list becomes too broad, legitimate crisis signals will be missed.
Step 17: Are-You-There Reassurance
File: safety.py:406–443 (wire_session_safety)
Input: user_input_transcribed event (fired on every finalized STT result)
Logic:
- Pattern match:
^(hello|are you there|are you still there|you there|anybody there|anyone there)$ - Only fires if agent is NOT currently speaking (
session.agent_state != "speaking"and nocurrent_speech) - Fires
session.generate_reply(instructions="Say briefly: 'Yes, I'm here! Just one moment.'") - Does NOT cancel pending LLM/tool work
Expected result: Caller hears reassurance within ~200ms when they think the agent went silent during processing.
Regression risk: If the guard session.agent_state == "speaking" check is removed, the reassurance collides with in-progress TTS output.
Step 18: LLM Inference
File: LiveKit Agents pipeline, verticals/church/agents.py:30–35 (LLM config)
Input: Full chat_ctx (conversation history + system prompt + per-turn RAG if injected)
Logic:
- Gemini 2.5 Flash is called first (primary)
- If Gemini fails, Claude Haiku 4.5 is called (fallback via
llm.FallbackAdapter) max_tool_steps=5— agent can call up to 5 tools before forcing a response- Response streamed back token by token
- After full response,
_agent_asked_questionupdated (True if?in response)
Third-party calls: Google Gemini 2.5 Flash, Anthropic Claude Haiku 4.5 (fallback)
Expected result: Agent generates contextually appropriate, church-branded, HEAR-protocol-compliant response in 500ms–2s.
Regression risk: If both LLMs fail, LiveKit's FallbackAdapter raises. The call crashes and _speak_error_and_hangup runs.
Step 19: TTS Output
Input: LLM response text (streamed)
Logic:
- Text streamed to Cartesia Sonic TTS with church-specific voice ID
- Cartesia synthesizes in real-time (streaming, not batch)
- Audio plays to caller via LiveKit room
- If Cartesia fails, Google TTS fallback activates
Third-party calls: Cartesia Sonic (streaming TTS), Google Cloud TTS (fallback)
Expected result: Caller hears natural speech in the church's chosen voice. Typical latency: 100–300ms first audio.
Regression risk:
FORMATTING_RULESin the system prompt instructs the LLM to spell out numbers and avoid colons. If this rule is weakened, TTS will mispronounce "9:00 AM" and phone numbers.- If Cartesia has an outage (ref: feedback_cartesia_vendor_risk.md — 5-day outage history), Google TTS takes over. Google TTS voice is generic and doesn't match the church's configured voice. Callers will notice.
Step 20: Tool Execution
When the LLM decides to call a tool, LiveKit executes the @function_tool method.
Tool: transfer_to_care
File: verticals/church/agents.py:269–296
Trigger: Coordinator LLM decides caller needs pastoral/emotional care. LLM MUST have: (1) empathized, (2) asked consent, (3) received affirmative response, before calling this tool.
Logic:
- Creates
CareAgentinstance:care_voice = get_opposite_voice(coordinator_voice_id)— opposite gender voice- If
church.get("care_voice_id")is set, uses that instead - Instructions:
build_care_prompt(church)+ tradition_context + rag_context - LLM:
_care_llm()— Claude Haiku 4.5 primary, Gemini 2.5 Flash fallback - TTS: Cartesia with care voice ID (different from Coordinator)
- Copies
call_contextfrom Coordinator to CareAgent - Returns
(care_agent, "Switching to the Care Agent now.")
CareAgent.on_enter:
- Uses
session.say()with fixed greeting:"Hi, I'm here with you now. Take your time, what's on your heart?" allow_interruptions=False— caller hears full greeting
Expected result: Caller hears a different (opposite gender) voice say a warm pastoral greeting. The voice change signals they are now in pastoral care mode.
HEAR impact: The hardcoded greeting is the most empathetic possible opener for someone in distress. "Take your time, what's on your heart?" does not interrogate or jump to solutions.
Regression risk: If the consent check in the system prompt is weakened, the agent may transfer without asking. This feels abrupt and violates HEAR protocol. The TOPIC AWARENESS section explicitly says "WAIT for their response."
Tool: submit_prayer_request
File: verticals/church/agents.py:336–363 (Coordinator), verticals/church/agents.py:122–151 (Care), verticals/church/tools.py:21–69
Parameters: prayer_text, caller_name, is_confidential
Database operations:
INSERT INTO voice_prayer_requests (
church_id, caller_phone, caller_name, prayer_text,
is_confidential, status, created_at
) VALUES (?, ?, ?, ?, ?, 'new', NOW())
Post-insert notifications (fire-and-forget):
- Get recipients:
church_team_membersWHEREchurch_id = ? AND is_active = true AND role IN ('prayer_team', 'care_team', 'admin') - Send email via
send_prayer_request_email()→ Resend API
Returns: {"success": True, "message": "..."} or {"success": False, "error": True, "message": "FAILED: ..."}
Tool honesty: If success=False, LLM is instructed to tell caller truthfully. Never fabricate confirmation.
Tool: request_callback
File: verticals/church/agents.py:366–408 (Coordinator), verticals/church/agents.py:153–195 (Care), verticals/church/tools.py:72–131
Parameters: caller_name, reason, preferred_time, phone_number, urgency (normal|urgent|pastoral_emergency), agreed_day, agreed_time_window
Database operations:
INSERT INTO voice_callback_requests (
church_id, caller_phone, caller_name, reason, preferred_time,
urgency, agreed_day, agreed_time_window, status, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', NOW())
Post-insert notifications (fire-and-forget):
- Email recipients:
church_team_membersWHERErole IN ('admin', 'office_admin') - Email via
send_callback_request_email()→ Resend API - If
urgency in ("urgent", "pastoral_emergency")ANDnotification_phoneis set:- SMS via
send_urgent_callback_sms()→ Twilio
- SMS via
Tool: capture_visitor_contact
File: verticals/church/agents.py:300–333, verticals/church/tools.py:133–179
Parameters: visitor_name, reason_for_visit, email, phone_number
Database operations:
INSERT INTO voice_visitor_contacts (
church_id, caller_phone, caller_name, reason, caller_email, created_at
) VALUES (?, ?, ?, ?, ?, NOW())
Post-insert notifications: Email to church_team_members WHERE role IN ('care_team', 'admin', 'volunteer_coordinator')
Tool: register_for_event
File: verticals/church/agents.py:469–496, verticals/church/tools.py:182–212
Parameters: event_name, attendee_name, attendee_email, attendee_phone
Database operations: INSERT INTO voice_visitor_contacts with reason = "Event registration: {event_name}" — reuses visitor contacts table.
Tool: send_directions_link
File: verticals/church/agents.py:410–424, core/tools.py:75–82
Logic:
- Builds Google Maps URL:
https://maps.google.com/maps?q={encoded_address} - Calls
_send_sms_link()→send_sms()→ Twilio REST API
Database operations: None.
Tool: send_giving_link
File: verticals/church/agents.py:426–441, core/tools.py:53–72
Logic: Reads church_data.giving_url → SMS to caller via Twilio.
Tool: send_sms_link
File: verticals/church/agents.py:443–466, core/tools.py:53–72
Parameters: url, message
Logic: Any URL → SMS to caller via Twilio.
Tool: check_availability (Cal.com)
File: verticals/church/agents.py:500–526, verticals/church/integrations/cal.py
Logic:
- Requires
church.cal_event_type_idandchurch.cal_api_key - Calls Cal.com availability API:
GET /v1/availability?eventTypeId={id}&dateFrom={date}&dateTo={date} - Returns available slot times as string
Third-party calls: Cal.com REST API
Tool: book_appointment (Cal.com)
File: verticals/church/agents.py:528–583, verticals/church/integrations/cal.py
Parameters: slot_time, caller_name, caller_email, caller_phone, notes
Logic:
- Calls Cal.com booking API:
POST /v1/bookings - If
caller_email: sendssend_appointment_confirmation_email()via Resend
Third-party calls: Cal.com REST API, Resend email API
Tool: get_service_times / get_upcoming_events / get_staff_directory (Planning Center)
File: verticals/church/agents.py:588–618, verticals/church/integrations/planning_center.py
Logic:
- Requires
church.pco_app_idandchurch.pco_secret - Calls Planning Center API (services, calendar, people modules)
Third-party calls: Planning Center REST API
Tool: end_call
File: verticals/church/agents.py:621–625 (Coordinator), agents.py:197–202 (Care), core/tools.py:18–50
Logic:
async def end_call_gracefully(session, agent_name):
speech = session.current_speech
if speech:
await speech.wait_for_playout() # Wait for farewell TTS
else:
await asyncio.sleep(4)
job_ctx = get_job_context()
await job_ctx.api.room.delete_room(
lk_api.DeleteRoomRequest(room=job_ctx.room.name)
)
return {"status": "ending_call"}
- Waits for TTS to finish playing before deleting room
- Deleting the LiveKit room hangs up the SIP call on both Telnyx and Twilio
Third-party calls: LiveKit API (delete_room)
Step 21: Mutual Farewell Auto-Hangup
File: main.py:294–353, call_handler.py:128–157
Logic:
conversation_item_addedevent fires for every LLM response and every caller utterance- When
role == "assistant": last agent text stored in_last_agent_text - When
role == "user":is_mutual_farewell(last_agent_text, caller_msg)is checked is_mutual_farewellrequires:- Agent said a farewell phrase: "take care", "have a blessed", "god bless", "goodbye", etc.
- Caller responded with: "bye", "good night", "that's all", "nothing else", OR a short "thank you" (≤6 words)
- If both match:
_auto_hangup_after_farewell()schedules room deletion after 2-second grace period - Grace period prevents cutting off mid-word if the caller is still talking
Expected result: Call ends ~2 seconds after mutual farewell. No need for caller to say anything special or press any button.
HEAR impact: NEVER apply this during crisis mode. Crisis callers must control when to end the call.
Regression risk: If agent farewell phrases list changes to be less specific, benign phrases like "take care" in mid-call could trigger premature hangup. The agent phrase list should only include phrases used in sign-offs.
Step 22: Post-Call Classification
File: session.py:550–709 (classify_call)
Trigger: _finalize_call() runs as a shutdown callback (ctx.add_shutdown_callback(_finalize_call)).
Input: _captured_transcript: list[dict] (accumulated via conversation_item_added events), church_data
Logic — 3-level fallback:
Level 1: Gemini 2.5 Flash (primary)
- Prompt: classify transcript → JSON with 7 fields
- Safety settings: ALL set to
BLOCK_NONE— classification MUST work on crisis transcripts - 30-second timeout
- Parses JSON response, validates all 7 fields
Level 2: Gemini 2.0 Flash (fallback)
- Same prompt, same settings, same timeout
- Used if 2.5 Flash times out or returns blocked response
Level 3: Keyword fallback (safety net)
_detect_crisis_from_transcript()— regex patterns matching crisis indicators_detect_topic_from_transcript()— keyword heuristics for common call types- Used if both Gemini models fail
- Crisis fallback ALWAYS returns
urgency="critical"andsuggested_assignee="pastor"— crisis calls MUST NOT be mis-classified as low urgency even if LLM fails
Classification output fields:
summary: str (1-2 sentences, max 500 chars)
caller_sentiment: float (-1.0 to 1.0)
call_topics: list[str] (from: service_times, directions, visitor_info, prayer, pastoral_care, crisis, giving, events, volunteer, children_ministry, small_groups, callback, general_info, other)
category: str (information|prayer|pastoral|giving|event|support|crisis|other)
urgency: str (low|medium|high|critical)
follow_up_needed: bool
suggested_assignee: str|None (pastor|office_admin|care_team|prayer_team|volunteer_coordinator|null)
Third-party calls: Google Gemini 2.5 Flash, Google Gemini 2.0 Flash (fallback)
Expected result: Every completed call has an AI-generated summary, sentiment score, and urgency flag in the database. Pastor dashboard shows which calls need follow-up.
HEAR impact: Indirect — classification errors can cause pastoral staff to miss urgent follow-up calls.
Regression risk:
AgentServer(shutdown_process_timeout=60)was specifically set to give classify_call 60 seconds. If reduced, classification will be killed mid-call and DB will not be updated.- The
_sourcefield (set by classify_call, removed by update_call_log_end before writing to DB) tracks which path was used — available in logs for debugging.
Step 23: Call Log Update (End of Call)
File: session.py:712–798 (update_call_log_end)
Input: call_id (call_sid), transcript, duration, classification dict
Logic:
- Updates the
voice_call_logsrow inserted in Step 8 - Retries once after 0.5s on failure
- Non-fatal: logs errors but does not propagate
Database operations:
UPDATE voice_call_logs SET
status = 'completed',
duration_seconds = ?,
transcript = ?, -- JSONB array of {role, content}
summary = ?, -- AI-generated from classify_call
caller_sentiment = ?,
call_topics = ?, -- text[] array
category = ?,
urgency = ?,
follow_up_needed = ?,
suggested_assignee = ?
WHERE call_sid = ?
Expected result: Complete call record in database. Admin dashboard shows transcript, summary, urgency, and follow-up flag.
Regression risk: If call_sid doesn't match (e.g. empty call_id), the UPDATE finds 0 rows and silently does nothing. The initial insert_call_log row stays with status = "in_progress" forever.
Database Tables — Complete Reference
| Table | Operations | Who writes |
|---|---|---|
voice_call_logs | INSERT (call start), UPDATE (call end) | session.py |
voice_prayer_requests | INSERT | verticals/church/tools.py |
voice_callback_requests | INSERT | verticals/church/tools.py |
voice_visitor_contacts | INSERT (visitor + event registration) | verticals/church/tools.py |
moderation_violations | INSERT (threat/crisis/abuse) | moderation.py (via safety.py) |
church_voice_agents | SELECT (church config), RPC increment | supabase_church.py, session.py |
churches | SELECT (JOIN with church_voice_agents) | supabase_church.py |
premium_churches | SELECT (plan, subscription status) | supabase_church.py |
organization_settings | SELECT (agent_config) | supabase_church.py |
church_knowledge_base | SELECT (inline FAQs) | session.py |
church_team_members | SELECT (notification recipients) | core/notifications.py |
product_knowledge | SELECT (product FAQ injection) | session.py |
tradition_care_context | SELECT (tradition-specific care prompts) | session.py |
qa_testing_config | SELECT (test mode redirect) | core/notifications.py |
unified_rag_content | SELECT via RPC search_unified_rag_content | core/rag.py |
Supabase RPCs:
search_church_knowledge(p_church_id, p_query_embedding, p_match_threshold, p_match_count)— vector search church KBsearch_unified_rag_content(query_embedding, p_theological_lens_ids, ...)— vector search unified KBincrement_voice_call_count(p_church_id)— atomic counter increment
Third-Party Services — Complete Reference
| Service | Purpose | Credentials |
|---|---|---|
| LiveKit Cloud | Call routing, room management, audio pipeline | LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_URL |
| Telnyx | SIP for new customer numbers (direct to LiveKit) | Configured in LiveKit SIP trunk |
| Twilio | SIP trunk for legacy numbers + SMS sending | TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_SMS_FROM |
| Deepgram | STT primary (Nova-3, streaming) | DEEPGRAM_API_KEY |
| Cartesia | TTS primary (Sonic, streaming, per-church voice) | CARTESIA_API_KEY |
| Google Cloud | STT fallback + TTS fallback | GOOGLE_APPLICATION_CREDENTIALS or GOOGLE_API_KEY |
| Google Gemini | LLM primary for Coordinator + classification | GEMINI_API_KEY or GOOGLE_API_KEY |
| Anthropic Claude | LLM fallback for Coordinator, primary for Care | ANTHROPIC_API_KEY |
| OpenAI | Embeddings only (text-embedding-3-small) | OPENAI_API_KEY |
| Resend | Email notifications to church staff | RESEND_API_KEY, EMAIL_FROM |
| Supabase | Database (all read/write) | SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY |
| Cal.com | Appointment scheduling (per-church optional) | church.cal_api_key (per-church) |
| Planning Center | Service times, events, staff (per-church optional) | church.pco_app_id, church.pco_secret (per-church) |
Environment Variables — Complete Reference
All loaded from .env and .env.local at startup (load_dotenv in main.py).
| Variable | Used by | Purpose |
|---|---|---|
LIVEKIT_API_KEY | LiveKit SDK | Room API calls (delete_room) |
LIVEKIT_API_SECRET | LiveKit SDK | Room API auth |
LIVEKIT_URL | LiveKit SDK | Cloud endpoint |
SUPABASE_URL | session.py | Supabase project URL |
SUPABASE_SERVICE_ROLE_KEY | session.py | Service role key (full DB access) |
DEEPGRAM_API_KEY | main.py (deepgram.STT) | STT |
CARTESIA_API_KEY | main.py (cartesia.TTS) | TTS |
GOOGLE_API_KEY | Gemini LLM, Google TTS/STT | Multi-purpose Google credential |
GEMINI_API_KEY | session.py classify_call | Post-call classification (can same as GOOGLE_API_KEY) |
ANTHROPIC_API_KEY | anthropic.LLM | Claude Haiku fallback |
OPENAI_API_KEY | core/rag.py | Embeddings only |
TWILIO_ACCOUNT_SID | core/notifications.py | SMS sending |
TWILIO_AUTH_TOKEN | core/notifications.py | SMS auth |
TWILIO_SMS_FROM | core/notifications.py | Sender phone number |
RESEND_API_KEY | core/notifications.py | Email sending |
EMAIL_FROM | core/notifications.py | Sender name/address (default: ChurchWiseAI <notifications@churchwiseai.com>) |
Multi-Tenant Architecture
ONE deployed agent serves ALL churches. Per-church isolation is achieved entirely through runtime data loading:
PHONE_REGISTRYmaps phone numbers to church UUIDs (static, in-memory)lookup_church_by_phone()handles numbers not in the static registry (DB lookup, 5-minute cache)load_church_data()loads full church config per call (1-minute cache)- System prompt is built fresh for each call with church-specific data
- Voice ID is per-church (church configures Cartesia voice ID in admin dashboard)
- Notifications go to church-specific
notification_email/notification_phone - DB writes include
church_idon all records — complete data isolation
No code deployment is needed to add a new customer. Provision: Telnyx number + church_voice_agents row + add to PHONE_REGISTRY (or rely on DB lookup).
Caching Reference
| Data | Cache Key | TTL | Notes |
|---|---|---|---|
| Church config | church:{church_id} | 60s | Short for fast subscription propagation |
| Phone → church_id | phone:{to_number} | 300s | DB fallback for unknown numbers |
| Product knowledge | pk:all | 900s | Shared across all calls |
| Inline FAQs | faq:{church_id} | 300s | Per-church |
| Tradition context | tradition:{tradition_key} | 900s | Per-tradition (17 traditions) |
Cache implementation: in-process Python dict with time.monotonic() expiry. Per-worker-process — not shared between LiveKit worker processes. cache_get_stale() returns expired values on DB errors (stale-while-revalidate).
Safety Architecture Summary
Three independent safety layers, each covering different vectors:
| Layer | Mechanism | When | What |
|---|---|---|---|
| Pre-LLM (SafeAgent.llm_node) | Regex patterns | Every utterance | Threat, crisis, abuse, noise |
| In-prompt (CRISIS_PROTOCOL) | LLM instruction | LLM sees crisis content | 988 immediately, stay present |
| Post-call (classify_call) | Gemini + keyword fallback | After call ends | Crisis flagged in DB for follow-up |
Crisis routing rule: Pre-LLM crisis detection BYPASSES the LLM entirely — the fixed CRISIS_RESPONSE is yielded directly. The LLM never sees the crisis content. This is intentional: no risk of the LLM mishandling sensitive content.
Threat routing rule: Same bypass — THREAT_RESPONSE is yielded, notifications fire, return. The LLM never sees threat content.
Post-classification safety net: _detect_crisis_from_transcript() runs as last fallback in classify_call(). A crisis call can NEVER be classified as "low urgency" even if both Gemini models fail, because the keyword fallback will force urgency="critical" and suggested_assignee="pastor".
Agent Types Summary
| Agent | LLM | Voice | Use Case |
|---|---|---|---|
| CoordinatorAgent | Gemini 2.5 Flash → Haiku | Church voice_id | All church calls — front door |
| CareAgent | Claude Haiku → Gemini | Opposite gender from Coordinator | Transferred for pastoral/emotional care |
| SalesAgent | Gemini 2.5 Flash → Haiku | Carson or Brooke (random) | Toll-free number (+18886030316) |
| DemoRouterAgent | Gemini 2.5 Flash → Haiku | Carson | Demo lines — offers church choice |
| DemoAgent | Gemini 2.5 Flash → Haiku | Church voice | Demo — simulates real church call |
HEAR Protocol in the Pipeline
The HEAR protocol is embedded in the system prompt (section 3 of both Coordinator and Care prompts) and enforced at multiple pipeline stages:
| Stage | HEAR Impact |
|---|---|
| greeting (on_enter) | Warm, AI-disclosed, non-interrogating opening |
| Noise filter | Drops "okay", "right", etc. to prevent agent treating acknowledgments as new topics |
| Crisis check | Hard override — 988 immediately, then listen |
| Care handoff | Consent-based, must empathize first, must wait for yes |
| CareAgent.on_enter | "Take your time, what's on your heart?" — space before capture |
| Prayer request prompts | Empathize → name → submit (no over-questioning) |
| CLEAN_ENDINGS fragment | Proactive warm close, one "anything else?" max |
Protected Code Paths
These files are LIFE-SAFETY protected. Changes require explicit founder approval:
moderation.py— crisis and threat regex patternscore/prompt_fragments.py— CRISIS_PROTOCOL, HEAR_PROTOCOLverticals/church/prompts.py— what the AI says to callers in crisissafety.py— pre-LLM safety layer wiring
Never remove or weaken crisis patterns without clinical review.