Skip to main content

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

FileRole
main.pyEntrypoint, session wiring, agent path builders
session.pyPHONE_REGISTRY, routing, Supabase singleton, call log lifecycle, classify_call, caching, all loaders
safety.pySafeAgent base class — pre-LLM safety layer (threat/crisis/abuse/noise/per-turn RAG)
call_handler.pyNoise filtering, mutual farewell detection, "are you there?" detection
moderation.pyRegex patterns for threat, crisis, abuse detection
core/rag.pyOpenAI embedding generation, Supabase RPC search, context formatting
core/tools.pyend_call_gracefully, _send_sms_link, _send_directions_link
core/notifications.pyResend email + Twilio SMS, all notification types, QA test redirect
core/prompt_fragments.pyCRISIS_PROTOCOL, HEAR_PROTOCOL, NATURAL_SPEECH, all 14 shared prompt fragments
verticals/church/agents.pyCoordinatorAgent, CareAgent — all @function_tool methods
verticals/church/prompts.pybuild_coordinator_prompt(), build_care_prompt() — full prompt builders
verticals/church/tools.pyDB write implementations: prayer, callback, visitor, event
verticals/church/config.pyDefault voice IDs, TIER_AGENTS, get_opposite_voice()
verticals/church/tradition_care_contexts.py17 tradition contexts for Care Agent calibration
verticals/church/integrations/supabase_church.pyload_church_data() — 3 DB queries + cache
verticals/church/integrations/cal.pyCal.com availability + booking API
verticals/church/integrations/planning_center.pyPlanning Center services, events, staff directory
verticals/sales/agents.pySalesAgent, DemoRouterAgent, DemoAgent
verticals/sales/prompts.pybuild_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_cYzx7sAkUTvx and SDR_Wpyno7GDNQqg match agent_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:

  1. await ctx.connect() — Agent connects to the LiveKit room. MUST happen before wait_for_participant (LiveKit/agents#4861).
  2. await ctx.wait_for_participant() — Waits for the SIP participant (the caller) to join.
  3. Extract SIP attributes from participant.attributes:
    • sip.phoneNumbercaller_phone (the caller's number, e.g. +16155551234)
    • sip.trunkPhoneNumberdialed_number (the church's number, e.g. +14144007103)
    • sip.callIDcall_id (LiveKit call UUID, used as call_sid in DB)
  4. Normalize: Telnyx may omit + prefix — add it if missing.
  5. 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):

  1. Check SALES_NUMBERS set — if match, return ("sales", None)
  2. Check DEMO_NUMBERS set — if match, return ("demo_router", None)
  3. Check PHONE_REGISTRY dict — if match, return ("church", church_id) (or ("church", None) for unassigned)
  4. 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_config table for (church_id){tester_phone, tester_email}
  • If found, sets _current_redirect global in notifications.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:

  1. Check in-memory cache key church:{church_id} (TTL: 60 seconds)
  2. If cache miss: run 3 Supabase queries in sequence:
    • _fetch_voice_agent_row()church_voice_agents + churches + premium_churches JOIN
    • _fetch_agent_config()organization_settings for agent personality overrides
    • _fetch_premium_tier()premium_churches for plan and subscription status
  3. Enforce subscription: if status not in ("active", "unknown") → return None (call rejected)
  4. Enforce call limit: if calls_this_month >= calls_limit → return None (call rejected)
  5. Assemble result dict with 40+ fields (see below)
  6. Cache result for 60 seconds
  7. 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 None from the dict. ALWAYS verify columns via information_schema before 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_logs record with status = "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 OpenAI text-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 KB
    • search_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_knowledge table: 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_base WHERE organization_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_logs WHERE from_number = caller_phone AND church_id = ? AND created_at >= 90_days_ago ORDER BY created_at DESC LIMIT 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_context WHERE tradition_key = ? AND is_active = true LIMIT 1
  • Falls back to Python module tradition_care_contexts.py if 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_KEY is missing, RAG is silently disabled (empty string). Agent has no church KB context.
  • If SUPABASE_SERVICE_ROLE_KEY is 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:

  1. Call build_coordinator_prompt(church) — pure function, returns system prompt string
  2. If tradition_context contains TRADITION-SPECIFIC AVOIDS:, extract and append to instructions
  3. Append full_rag_context as --- KNOWLEDGE BASE --- block
  4. 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):

  1. Identity: "You are the AI receptionist for {name}. You are calm, warm, and welcoming."
  2. NATURAL_SPEECH — filler phrases, context-matching rules
  3. HEAR_PROTOCOL — 4-step empathy framework (Hear, Empathize, Advance, Respond)
  4. 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)
  5. Church facts (address, denomination, phone, website, hours, staff, ministries, what_to_expect, events)
  6. Custom FAQs (if configured)
  7. Pastor's Pulse (sermon topic, series, theme verse, weekly announcement)
  8. TOPIC AWARENESS block — what to handle directly vs. hand off
  9. GIVING & STEWARDSHIP block — zero-pressure giving guidance
  10. DEMANDING A REAL PERSON protocol
  11. LANGUAGE block (Spanish/multilingual — tool data always in English)
  12. Personality overrides from agent_config.coordinator.personalityOverrides
  13. --- KNOWLEDGE BASE --- (RAG + product knowledge + FAQs + repeat history + datetime)
  14. 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_RULES enforces 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=True can 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_safety from safety.py) — hooks user_input_transcribed for "are you there?" detection
  • Set agent.call_context dict: {call_id, caller_phone, dialed_number, church_data, supabase}
  • await session.start() with:
    • room_optionsnoise_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() with allow_interruptions=False (not generate_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 of session.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
  • MultilingualModel turn 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=True starts 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_question updated 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_violations table
    • 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)

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_violations table
    • 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)

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" → yield ABUSE_WARNING → no LLM call
  • Strike 2 → "end_call" → log violation + notify church admin + yield ABUSE_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_base via search_church_knowledge RPC (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 in chat_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 no current_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_question updated (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_RULES in 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:

  1. Creates CareAgent instance:
    • 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)
  2. Copies call_context from Coordinator to CareAgent
  3. 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_members WHERE church_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_members WHERE role IN ('admin', 'office_admin')
  • Email via send_callback_request_email() → Resend API
  • If urgency in ("urgent", "pastoral_emergency") AND notification_phone is set:
    • SMS via send_urgent_callback_sms() → Twilio

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.


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.


File: verticals/church/agents.py:426–441, core/tools.py:53–72

Logic: Reads church_data.giving_url → SMS to caller via Twilio.


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_id and church.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: sends send_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_id and church.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_added event 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_farewell requires:
    • 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" and suggested_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 _source field (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_logs row 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

TableOperationsWho writes
voice_call_logsINSERT (call start), UPDATE (call end)session.py
voice_prayer_requestsINSERTverticals/church/tools.py
voice_callback_requestsINSERTverticals/church/tools.py
voice_visitor_contactsINSERT (visitor + event registration)verticals/church/tools.py
moderation_violationsINSERT (threat/crisis/abuse)moderation.py (via safety.py)
church_voice_agentsSELECT (church config), RPC incrementsupabase_church.py, session.py
churchesSELECT (JOIN with church_voice_agents)supabase_church.py
premium_churchesSELECT (plan, subscription status)supabase_church.py
organization_settingsSELECT (agent_config)supabase_church.py
church_knowledge_baseSELECT (inline FAQs)session.py
church_team_membersSELECT (notification recipients)core/notifications.py
product_knowledgeSELECT (product FAQ injection)session.py
tradition_care_contextSELECT (tradition-specific care prompts)session.py
qa_testing_configSELECT (test mode redirect)core/notifications.py
unified_rag_contentSELECT via RPC search_unified_rag_contentcore/rag.py

Supabase RPCs:

  • search_church_knowledge(p_church_id, p_query_embedding, p_match_threshold, p_match_count) — vector search church KB
  • search_unified_rag_content(query_embedding, p_theological_lens_ids, ...) — vector search unified KB
  • increment_voice_call_count(p_church_id) — atomic counter increment

Third-Party Services — Complete Reference

ServicePurposeCredentials
LiveKit CloudCall routing, room management, audio pipelineLIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_URL
TelnyxSIP for new customer numbers (direct to LiveKit)Configured in LiveKit SIP trunk
TwilioSIP trunk for legacy numbers + SMS sendingTWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_SMS_FROM
DeepgramSTT primary (Nova-3, streaming)DEEPGRAM_API_KEY
CartesiaTTS primary (Sonic, streaming, per-church voice)CARTESIA_API_KEY
Google CloudSTT fallback + TTS fallbackGOOGLE_APPLICATION_CREDENTIALS or GOOGLE_API_KEY
Google GeminiLLM primary for Coordinator + classificationGEMINI_API_KEY or GOOGLE_API_KEY
Anthropic ClaudeLLM fallback for Coordinator, primary for CareANTHROPIC_API_KEY
OpenAIEmbeddings only (text-embedding-3-small)OPENAI_API_KEY
ResendEmail notifications to church staffRESEND_API_KEY, EMAIL_FROM
SupabaseDatabase (all read/write)SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY
Cal.comAppointment scheduling (per-church optional)church.cal_api_key (per-church)
Planning CenterService 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).

VariableUsed byPurpose
LIVEKIT_API_KEYLiveKit SDKRoom API calls (delete_room)
LIVEKIT_API_SECRETLiveKit SDKRoom API auth
LIVEKIT_URLLiveKit SDKCloud endpoint
SUPABASE_URLsession.pySupabase project URL
SUPABASE_SERVICE_ROLE_KEYsession.pyService role key (full DB access)
DEEPGRAM_API_KEYmain.py (deepgram.STT)STT
CARTESIA_API_KEYmain.py (cartesia.TTS)TTS
GOOGLE_API_KEYGemini LLM, Google TTS/STTMulti-purpose Google credential
GEMINI_API_KEYsession.py classify_callPost-call classification (can same as GOOGLE_API_KEY)
ANTHROPIC_API_KEYanthropic.LLMClaude Haiku fallback
OPENAI_API_KEYcore/rag.pyEmbeddings only
TWILIO_ACCOUNT_SIDcore/notifications.pySMS sending
TWILIO_AUTH_TOKENcore/notifications.pySMS auth
TWILIO_SMS_FROMcore/notifications.pySender phone number
RESEND_API_KEYcore/notifications.pyEmail sending
EMAIL_FROMcore/notifications.pySender 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:

  1. PHONE_REGISTRY maps phone numbers to church UUIDs (static, in-memory)
  2. lookup_church_by_phone() handles numbers not in the static registry (DB lookup, 5-minute cache)
  3. load_church_data() loads full church config per call (1-minute cache)
  4. System prompt is built fresh for each call with church-specific data
  5. Voice ID is per-church (church configures Cartesia voice ID in admin dashboard)
  6. Notifications go to church-specific notification_email / notification_phone
  7. DB writes include church_id on 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

DataCache KeyTTLNotes
Church configchurch:{church_id}60sShort for fast subscription propagation
Phone → church_idphone:{to_number}300sDB fallback for unknown numbers
Product knowledgepk:all900sShared across all calls
Inline FAQsfaq:{church_id}300sPer-church
Tradition contexttradition:{tradition_key}900sPer-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:

LayerMechanismWhenWhat
Pre-LLM (SafeAgent.llm_node)Regex patternsEvery utteranceThreat, crisis, abuse, noise
In-prompt (CRISIS_PROTOCOL)LLM instructionLLM sees crisis content988 immediately, stay present
Post-call (classify_call)Gemini + keyword fallbackAfter call endsCrisis 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

AgentLLMVoiceUse Case
CoordinatorAgentGemini 2.5 Flash → HaikuChurch voice_idAll church calls — front door
CareAgentClaude Haiku → GeminiOpposite gender from CoordinatorTransferred for pastoral/emotional care
SalesAgentGemini 2.5 Flash → HaikuCarson or Brooke (random)Toll-free number (+18886030316)
DemoRouterAgentGemini 2.5 Flash → HaikuCarsonDemo lines — offers church choice
DemoAgentGemini 2.5 Flash → HaikuChurch voiceDemo — 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:

StageHEAR Impact
greeting (on_enter)Warm, AI-disclosed, non-interrogating opening
Noise filterDrops "okay", "right", etc. to prevent agent treating acknowledgments as new topics
Crisis checkHard override — 988 immediately, then listen
Care handoffConsent-based, must empathize first, must wait for yes
CareAgent.on_enter"Take your time, what's on your heart?" — space before capture
Prayer request promptsEmpathize → name → submit (no over-questioning)
CLEAN_ENDINGS fragmentProactive 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 patterns
  • core/prompt_fragments.py — CRISIS_PROTOCOL, HEAR_PROTOCOL
  • verticals/church/prompts.py — what the AI says to callers in crisis
  • safety.py — pre-LLM safety layer wiring

Never remove or weaken crisis patterns without clinical review.