Skip to main content

NOTE: The legacy /api/chatbot/chat endpoint was deleted on 2026-04-09. The PRODUCTION chatbot endpoint is /api/chatbot/stream (src/app/api/chatbot/stream/route.ts). This document traces the /api/chatbot/stream pipeline — all chatbot traffic flows through this single endpoint.

Chatbot Conversation Pipeline — Complete Code Trace

Route: POST /api/chatbot/stream (PRODUCTION) File: src/app/api/chatbot/stream/route.ts
Last verified against: 2026-04-02 full code read


High-Level Cascade (Mermaid)


Step-by-Step Pipeline

Step 1: Rate Limiting

File: src/app/api/chatbot/stream/route.ts:30,119-121
Input: Client IP address from request headers
Logic:

  • Limiter initialized at module level: 30 requests per 60-second window per IP
  • getClientIP(request) extracts IP from x-forwarded-for, x-real-ip, or fallback
  • limiter(ip) checks in-memory token bucket; returns { success: boolean }
  • If !successgetRateLimitResponse() returns HTTP 429

Output: Passes through or returns 429
Third-party calls: None
Database operations: None
Expected result: Legitimate church visitors (low volume) always pass. Scrapers/bots are throttled before any DB work.
HEAR impact: Protects the system so genuine visitors always receive responses.
Regression risk: Raising the limit too high enables prompt injection at scale; too low blocks legitimate bursts during Sunday announcements.


Step 2: Bot Detection

File: src/app/api/chatbot/stream/route.ts:126-132
Input: Request object
Logic:

  • Calls checkBotId() from botid/server package
  • If isBot → logs warning but does NOT block (rate limit is the enforcement mechanism)
  • checkBotId() failure is non-fatal (caught, logged as warning)

Output: Logs suspicious traffic
Third-party calls: botid/server (local)
Database operations: None
Expected result: Crawlers are identified for analysis without breaking legitimate use.
Regression risk: If botid/server throws synchronously rather than rejecting, it could crash the handler — caught by try/catch.


Step 3: Request Parsing + Validation

File: src/app/api/chatbot/stream/route.ts:134-161
Input: Raw request body JSON
Logic:

  • Destructures: { message, churchId, sessionId, history, agentType, lensOverride, lensNameOverride }
  • Validates required fields: message, churchId, sessionId → 400 if missing
  • Checks ANTHROPIC_API_KEY || OPENAI_API_KEY → 503 if neither configured
  • Validates message.length > 2000 → 400 "Message too long"

Output: Typed fields or error response
Expected result: All fields present and within bounds.
Regression risk: No type coercion on churchId — callers could send non-UUID strings, which will fail downstream DB queries silently returning null.


Step 4: Origin Validation

File: src/app/api/chatbot/stream/route.ts:164-214
Input: Origin and Referer headers
Logic:

  • Trusted origins: churchwiseai.com, www.churchwiseai.com, sermonwise.ai, sharewiseai.com (+ localhost in dev)
  • Derives requestOrigin from Origin header, falling back to Referer URL origin
  • isTrustedOrigin = boolean (does NOT block; logs only)
  • Early church validation: Queries premium_churches for church_id + chatbot_enabled=true + status IN ('active','preview') — returns 404 if not found. This prevents arbitrary churchId abuse that would consume resources.
  • If suspicious origin and not the church's custom_domainconsole.warn

Database operations:

  • premium_churches SELECT: church_id, custom_domain WHERE church_id = $1 AND chatbot_enabled = true AND status IN ('active','preview') LIMIT 1

Expected result: Church exists and has chatbot enabled.
HEAR impact: Prevents fake church IDs from consuming LLM budget.
Regression risk: status IN ('active','preview') — if a church status changes to something else mid-conversation, subsequent turns get 404.


Step 5: Restriction Check (Pre-Moderation Gate)

File: src/app/api/chatbot/stream/route.ts:218-226 | src/lib/moderation.ts:55-99
Input: churchId, sessionId
Logic:

  • Queries user_restrictions for active restriction on this session
  • Filter: church_id = $1 AND user_identifier = $2 AND (expires_at IS NULL OR expires_at > now())
  • Returns message per restriction type:
    • cooldown (5 min): "I need to pause our conversation for a few minutes..."
    • temp_block (24 hr): "This conversation has been temporarily paused..."
    • permanent_block: "This conversation is no longer available..."
  • All restriction messages include 988 as a safety net
  • On error → fails open (returns restricted: false) to avoid blocking genuine visitors in crisis

Database operations:

  • user_restrictions SELECT: id, restriction_type, reason, expires_at WHERE church_id = $1 AND user_identifier = $2 AND (expires_at IS NULL OR expires_at > now())

Output: { restricted: boolean, type?, message?, expires_at? }
HEAR impact: Blocked sessions still receive crisis resources in the restriction message.
Regression risk: Session ID is the only user fingerprint — incognito or new tab creates a new session, bypassing temporary blocks.


Step 6: FAQ Matching — Stage 1 (Text Match)

File: src/app/api/chatbot/stream/route.ts:228-276 | src/lib/faq-matcher.ts
Input: message, churchId, agentType
Logic:

Cache load: loadChurchResponses(churchId) — 5-minute in-memory LRU per church

  • Queries canned_responses: id, question, answer, agent_type, exact_response, category, denomination_pack WHERE church_id = $1 AND is_active = true ORDER BY match_count DESC LIMIT 500
  • Pre-computes questionNormalized (lowercase, strip punctuation) and questionTokens (Set of non-stopword tokens) for each response

Text match algorithm:

  1. Exact match: questionNormalized === normalize(message) → similarity = 1.0
  2. Jaccard similarity: |A ∩ B| / |A ∪ B| on token sets → must exceed 0.75 to match

Decision tree:

  • exactResponse=true AND similarity ≥ 0.9short-circuit: return answer directly (zero LLM cost), track as canned, increment conversation counts
  • Fuzzy match → inject as preferredContext for LLM: "PREFERRED ANSWER (from church FAQ): Q: ... A: ..."

Stage 2 (vector match) if Stage 1 misses:

  • Generates embedding (OpenAI text-embedding-3-small)
  • Calls match_canned_responses RPC: p_match_threshold: 0.85, p_match_count: 1
  • Same decision tree: exactResponse=true AND similarity ≥ 0.90 → short-circuit; else preferred context

On any short-circuit:

  • trackUsage({ responseSource: 'canned', inputTokens: 0, outputTokens: 0, model: 'canned' })
  • supabase.rpc('increment_conversation_counts', ...)
  • supabase.rpc('increment_canned_match_count', { p_canned_id }) (fire-and-forget)
  • logQuestion(churchId, message, agentType) (fire-and-forget)
  • trackLatency({ cascadeTier: 'exact_match' })

Database operations:

  • canned_responses SELECT (with 5-min cache)
  • match_canned_responses RPC (vector match, Stage 2 only)
  • increment_canned_match_count RPC (fire-and-forget)

Third-party calls (Stage 2): OpenAI Embeddings API (text-embedding-3-small)
Expected result: Common church questions (hours, location, prayer request) answered instantly from the church's FAQ with no LLM call.
HEAR impact: FAQ answers are pastor-written with pastoral empathy — they should always take priority over raw structured data.
Regression risk: FAQ cache TTL is 5 minutes — FAQ changes take up to 5 min to propagate. invalidateFAQCache(churchId) must be called after FAQ CRUD.


Step 7: Church Data Loading (4 Parallel Queries)

File: src/app/api/chatbot/stream/route.ts:278-313
Input: churchId
Logic: All four run via Promise.all() — no sequential waiting.

Query 1 — churches:

  • Columns: id, name, denomination, address, phone, website, working_hours
  • Fatal if missing: returns 404 "Church not found"

Query 2 — premium_churches:

  • Columns: id, status, plan, chatbot_enabled, custom_name, custom_hours, custom_staff, custom_ministries, what_to_expect, cap_info
  • Filter: status IN ('active','preview')
  • If chatbot_enabled is false → 403

Query 3 — church_voice_agents:

  • Columns: pastor_name, callback_scheduling_enabled, cal_event_type_id, cal_api_key, pastor_availability_text, notification_email, sermon_topic, sermon_series, theme_verse, weekly_announcement, giving_enabled, giving_url, etransfer_email, giving_message
  • Non-fatal if missing (voice agent may not be configured)

Query 4 — organization_settings:

  • Columns: agent_tool_config, agent_config, chatbot_config
  • Non-fatal if missing

Expected result: All four rows loaded in one network round-trip (~50ms total instead of ~200ms sequential).
HEAR impact: All pastor-entered data (custom_staff, custom_ministries, etc.) is loaded here and flows into the system prompt.
Regression risk: CRITICAL — verify all columns exist before adding to SELECT. The source column incident broke 3 tables for weeks (see CLAUDE.md). Adding non-existent columns silently returns null for all rows.


Step 8: Usage Limit Check

File: src/app/api/chatbot/stream/route.ts:322-332
Input: churchId, rawPlan
Logic:

  • normalizePlanTier(rawPlan) maps any plan key to starter | pro | suite (see tier-config.ts)
  • checkUsageLimit(churchId, planTier) checks monthly conversation count against tier limit
  • If !usageCheck.allowed → returns soft-block response (not 4xx, so the widget can display the message)

Output: Pass-through or usage limit response
Expected result: Churches on Starter get ~500 conversations/month; Pro/Suite get higher limits.
Regression risk: pro_website plan uses special handling (rawPlan === 'pro_website' ? 'pro_website' : planTier) to avoid wrongly normalizing it to 'pro' for limit purposes.


Step 9: Derive Tool Config + Special Chatbot Types

File: src/app/api/chatbot/stream/route.ts:342-360
Logic:

  • Default tool list from DEFAULT_TOOL_CONFIG.chatbot_tools (all tools with chatbotDefault: true)
  • Override with orgSettings.agent_tool_config.chatbot_tools if set
  • Hard tier gate: filterToolsByTier(enabledChatbotTools, planTier) — strips tools above the plan's tier ceiling regardless of DB config
  • isBasicChatbot: chatbotConfig.source === 'pewsearch_auto_provision' — PewSearch auto-provisioned chatbot; Q&A only with 1 tool
  • isProWebsite: rawPlan === 'pro_website' && !isBasicChatbot — bundled website chatbot with restricted scope

Tier → Tool access:

  • starter (free tools only): submit_prayer_request, capture_visitor_contact, request_callback, get_church_directions, get_first_visit_info, get_sermon_info, get_announcements, lookup_bible_verse, send_connection_card_link, send_giving_link, register_for_event, flag_safety_concern
  • pro (+ pro tools): + book_appointment, find_small_group, signup_for_volunteer_role, subscribe_to_updates, send_message_to_staff, request_pastoral_visit, report_care_need, grief_support_resources, start_visitor_followup, conversation_summary, submit_benevolence_request, register_child_checkin, get_kids_info, schedule_counseling, daily_devotional, facility_booking, find_past_sermon, get_worship_playlist, lookup_local_resources, search_illustrations, generate_devotional, theological_deep_dive, generate_lesson_plan
  • suite (+ suite tools): + draft_follow_up_message, get_giving_history, detect_engagement_drop, generate_weekly_report

Step 10: Theological Lens Resolution

File: src/app/api/chatbot/stream/route.ts:396-435
Logic (priority order):

  1. Demo override: lensOverride param is a valid ID (1-17) + lensNameOverride is non-empty ≤ 50 chars → use directly (client selected tradition in demo mode). Name is sanitized: replace(/[^\w\s/()'-]/g, '').slice(0, 50)
  2. DB setting: Query church_theological_lenses JOIN sai_theological_lenses for this church → use stored theological_lens_id
  3. Denomination auto-detect: Look up church.denomination in DENOMINATION_TO_LENS map (40+ denomination strings mapped to 17 lens IDs) → query sai_theological_lenses for lens name
  4. Default: lensId = 10 (Christocentric), lensName = 'Christocentric'

Database operations (when not demo override):

  • church_theological_lenses JOIN sai_theological_lenses: SELECT theological_lens_id, sai_theological_lenses(lens_name) WHERE church_id = $1
  • sai_theological_lenses: SELECT lens_name WHERE lens_id = $1 (denomination fallback only)

Lens IDs (verified from DB):

  • 0=Universal, 1=Traditional, 2=Progressive/Social Justice, 3=Missional-Theological, 4=Reformed (Presbyterian), 5=Arminian (Wesleyan), 6=Lutheran, 7=Roman Catholic, 8=Anabaptist, 9=Pentecostal, 10=Christocentric, 11=Eastern Orthodox, 12=Black Church Tradition, 13=Anglican, 14=Baptist, 15=Charismatic, 16=Dispensational/Prophetic, 17=Liberation Theology

HEAR impact: The theological lens shapes doctrinal rules, vocabulary, and what the AI says (or refuses to say) on sensitive topics. Getting this wrong can cause spiritual harm.
Regression risk: DENOMINATION_TO_LENS is a static map in rag.ts — new denominations must be added manually.


Step 11: Tier 0 — Structured Data Fast Path

File: src/app/api/chatbot/stream/route.ts:437-475 | src/lib/response-cascade.ts:211-226
Input: message, churchFacts (churchName, address, phone, website, hours, staff, ministries, denomination, whatToExpect)
Logic:

Guards (return null = skip fast path):

  • ACTION_SKIP: matches action verbs → \b(call me|contact me|pray for|prayer request|visit me|schedule|book|sign up|register|volunteer|give|donate|callback|reach out)\b
  • PASTORAL_SKIP: 30+ emotional/crisis patterns → grief, crisis, mental health, abuse, addiction, illness, relationships, spiritual distress, nervousness, vulnerability signals. This ensures "My baby died" never matches the kids program structured response.
  • faqPreferredContext is set (FAQ fuzzy match found) → skip structured data entirely; FAQ content takes priority for pastoral empathy

Pattern matching (first match wins):

PatternReturns
service/worship timeFormatted hours with hedging disclaimer
address/locationAddress + "We'd love to see you!"
phone numberPhone number
website/urlWebsite URL
pastor/staffStaff list
denominationDenomination name
wear/dress codewhatToExpect.dress_code
parkingwhatToExpect.parking
kid/child/nursery/babywhatToExpect.children
youth/teenYouth ministry + children info
first visit/what to expectFull whatToExpect block
music stylewhatToExpect.music_style

On match: tracks usage (canned), returns immediately — zero LLM cost.
HEAR impact: Structured data is raw field data, not pastorally written. The PASTORAL_SKIP regex protects against cold data responses to emotional messages.
Regression risk: Adding new patterns without testing against PASTORAL_SKIP regex can cause emotional messages to hit the fast path.


Step 12: Context Building (4 Parallel Async Tasks)

File: src/app/api/chatbot/stream/route.ts:490-600
Input: lensId, churchId, message
Logic: All four run concurrently via Promise.all(). Each is non-fatal — caught errors produce empty blocks.

Task 1 — Doctrinal Rules:

  • Queries theological_contradictions WHERE lens_id = $1 ORDER BY doctrine_category
  • Returns: doctrine_category, primary_position, contrary_positions, must_include_terms, must_exclude_terms, explanation
  • Builds doctrinalRulesBlock: multi-line text listing each doctrine, position, must-include/exclude terms
  • Also queries organization_settings.doctrinal_overrides for custom church overrides (JSONB)
  • If church has custom practices: appends "CUSTOM CHURCH PRACTICES:" block

Task 2 — Lens Vocabulary:

  • Calls fetchLensVocabulary(lensId) → queries lens_knowledge table
  • buildChatVocabularyBlock(lensVocab, lensName) → produces text block with preferred/avoided terminology for this tradition

Task 3 — RAG Retrieval:

  • Calls generateEmbedding(message) → OpenAI text-embedding-3-small API → 1536-dim vector
  • Then runs TWO searches in parallel:
    1. searchRAG({ queryEmbedding, lensIds: [lensId], matchCount: 8 })search_unified_rag_content RPC, threshold 0.35
    2. searchChurchKnowledge(churchId, embedding)search_church_knowledge RPC, threshold 0.35, matchCount 8
  • formatRAGContext(results) → numbered list, each item: title + type + 600-char snippet
  • formatChurchKnowledgeContext(results) → numbered list with FAQ/Document label + 600-char snippet
  • Stores queryEmbedding and churchKnowledgeResults for cascade use

Task 4 — Product Knowledge:

  • Queries product_knowledge WHERE is_active = true ORDER BY priority DESC LIMIT 20
  • Returns category, question, answer for top 20 entries
  • Formats as: Q: ...\nA: ... block injected into system prompt

Database operations:

  • theological_contradictions SELECT (lens-filtered)
  • organization_settings SELECT doctrinal_overrides (church-specific)
  • lens_knowledge SELECT (via fetchLensVocabulary)
  • search_unified_rag_content RPC (Postgres pgvector cosine similarity on unified_rag_content)
  • search_church_knowledge RPC (Postgres pgvector on church_knowledge_base)
  • product_knowledge SELECT top 20 by priority

Third-party calls: OpenAI Embeddings API (Task 3 only; skipped if OPENAI_API_KEY not set)


Step 13: Cascade Tiers 2 & 3 (Fast Path Before Full LLM)

File: src/app/api/chatbot/stream/route.ts:658-694 | src/lib/response-cascade.ts:265-301
Input: churchId, message, queryEmbedding, churchKnowledgeResults
Logic: Only runs if queryEmbedding is non-null (OpenAI key present).

Tier 2 — Semantic Cache:

  • Calls checkSemanticCache(churchId, queryEmbedding)match_cached_response RPC
  • Threshold: 0.92 cosine similarity
  • Cache entries have 7-day TTL, stored in chatbot_response_cache
  • Crisis keywords excluded from cache: ['988', '741741', '911', 'crisis', 'suicide']
  • On hit: fire-and-forget increment_chatbot_cache_hit, return cached response immediately

Tier 3 — Direct Retrieval:

  • Checks churchKnowledgeResults[0].similarity > 0.90
  • If true: sends the top chunk to Haiku with a short formatting prompt:
    "You are a warm, friendly church assistant for [name]. Format the following church
    information into a natural, conversational response. Keep it to 1-2 sentences."
  • maxTokens: 200, temperature: 0.3
  • On success: caches response (cacheResponse), tracks usage as llm_haiku

Database operations:

  • match_cached_response RPC (pgvector cosine on chatbot_response_cache)
  • increment_chatbot_cache_hit RPC (fire-and-forget)
  • chatbot_response_cache INSERT (via cacheResponse, fire-and-forget)

HEAR impact: Semantic cache can return stale responses — hence crisis content is excluded to always serve fresh safety information.
Regression risk: If the cache threshold (0.92) is too low, semantically similar but context-different questions could return wrong cached responses. Too high = poor cache hit rate.


Step 14A: Basic Chatbot Path (PewSearch Auto-Provisioned)

File: src/app/api/chatbot/stream/route.ts:703-915
Trigger: isBasicChatbot = true (chatbotConfig.source === 'pewsearch_auto_provision')
Tools: Only submit_prayer_request
Max rounds: 2 (1 tool round + 1 text round)
maxTokens: 300
temperature: 0.3

System prompt includes:

  • Church facts block
  • Church knowledge block
  • Product knowledge block
  • FAQ fuzzy match block
  • HEAR protocol (empathy before tool use)
  • Prayer request instructions
  • Scope enforcement (church-only Q&A)
  • Inline crisis resources
  • Upsell CTA: "This is just one of 39 AI-powered ministry tools..." (appended after prayer request confirmation)

LLM call flow:

  1. Build basicMessages from history (last 10) + current message
  2. Call callLLM() with prayerToolSchema
  3. If tool called → executeTool('submit_prayer_request', args, ctx) → insert voice_prayer_requests
  4. Log tool to tool_invocations
  5. Follow-up call (no tools) to get final text
  6. Crisis safety net: regex test on original message → if 988/741741/911 missing → append full crisis block

Post-response (fire-and-forget):

  • trackUsage({ responseSource: 'basic_chatbot' })
  • increment_conversation_counts RPC
  • update_avg_response_time RPC
  • cacheResponse() to semantic cache

Step 14B: Pro Website Chatbot Path

File: src/app/api/chatbot/stream/route.ts:917-1246
Trigger: isProWebsite = true (rawPlan === 'pro_website' && !isBasicChatbot)
Tools: Only submit_prayer_request
Max rounds: 2 (PW_MAX_ROUNDS)
maxTokens: 400
temperature: 0.3

System prompt adds (beyond basic):

  • Theological context block (doctrinal rules + lens vocabulary)
  • Denomination hint
  • "Beyond your scope" instructions with soft CTA to churchwiseai.com (no "upgrade" language)
  • Documented theological position lookup before deferring to pastor
  • Language detection (English/Spanish)

Post-response extras:

  • logViolation() + autoEscalate() if crisis detected (unlike basic chatbot)
  • response_reviews INSERT (every response logged for admin review)
  • Returns upgradeUrl: 'https://churchwiseai.com/pricing' and upgradeMessage in JSON

Step 14C: Full Agentic Path (Starter / Pro / Suite Plans)

File: src/app/api/chatbot/stream/route.ts:1248-1952
Trigger: Not basic chatbot, not Pro Website
Tools: Up to 39 (tier-gated)
Max rounds: 3 (MAX_ROUNDS)

System Prompt Construction

The final system prompt is assembled in layers:

Layer 1 — Base agentic prompt (lines 1250-1462):

You are the AI care agent for {churchName}. You speak from the {lensName}
theological tradition. You are NOT a generic chatbot...

Contains:

  • Church facts block (factsBlock)
  • Church knowledge block (churchKnowledgeBlock)
  • RAG block (ragBlock) — curated theological content
  • Product knowledge block (productKnowledgeBlock)
  • FAQ preferred context (faqBlock)
  • MISSION section (HEAR: Hear, Empathize, Connect, Invite)
  • CONVERSATION STYLE (length rules, language mirroring, schedule hedging)
  • EMPATHETIC HEARING EXAMPLES (nervousness, grief, belonging)
  • CONTACT CAPTURE instructions
  • PRAYER REQUEST HANDLING (no praying, use tool, confirm success only)
  • THEOLOGICAL STANCE section (check lensName first, then defer)
  • PASTORAL CONNECT (Cal.com if configured, callback if not)
  • CRITICAL SAFETY RULE
  • SAFETY ESCALATION PROTOCOL (4 levels: inappropriate → profanity → threat → self-harm)
  • MEDICAL/LEGAL/FINANCIAL ADVICE rules
  • SCOPE ENFORCEMENT (church-only; youth exception)
  • NEVER list (fabricate names, pray, guarantee outcomes, etc.)
  • TOOLS list (all 39 with when-to-use guidance)
  • GIVING BEHAVIOR rules (natural, never guilt-trip, 1x max)

Layer 2 — Theological guardrails:

theologyPrompt = systemPrompt + doctrinalRulesBlock + lensVocabularyBlock

doctrinalRulesBlock appended verbatim: "IMPORTANT DOCTRINAL REQUIREMENTS: You MUST follow these theological positions..." lensVocabularyBlock: preferred/avoided terms for the tradition.

Layer 3 — Critical local resources:

  • Queries church_local_resources WHERE church_id = $1 AND is_active = true AND is_critical = true LIMIT 10
  • Appended as: "LOCAL EMERGENCY / CRISIS RESOURCES (provided by {churchName}):"
  • CAP info from premium_churches.cap_info also injected

Layer 4 — Agent specialization:

  • buildAgentSystemPrompt(agentType, enabledChatbotTools, personalityOverrides, handoffRules)
  • Appended only if agentType is set (persona-specific instructions)

Layer 5 — Care library (if escalated):

  • detectCareIntent(message, history) → identifies grief/crisis category
  • loadCareLibraryContext(category, subcategory, denomination) → fetches CPE-quality pre-built pastoral responses
  • buildCareSystemPrompt(careCtx, pastorName) → injected as additional context
  • When care library loaded → escalate = false (Haiku + care library > Sonnet improvising)

Prompt caching: Anthropic cache_control: { type: 'ephemeral' } on system prompt → ~60% input cost reduction on repeated turns.

Model Selection Logic

modelOverride 'gemini' → Gemini 2.5 Flash Lite (fallback to Haiku)
modelOverride 'haiku' → claude-haiku-4-5-20251001
modelOverride 'sonnet' → claude-sonnet-4-6
escalate=true + isToolRound → modelOverride='gemini' (Gemini for tool dispatch)
escalate=true + NOT isToolRound → Sonnet 4.6 (with 5s timeout, Haiku fallback)
escalate=false → Haiku 4.5 (default)

shouldEscalate(message, history, agentType) determines escalation:

  • Triggers on grief, crisis, theological depth, pastoral care need
  • Returns escalate: boolean

Tool-Use Loop

for (let round = 0; round <= MAX_ROUNDS; round++) {
response = callLLM(systemPrompt, currentMessages, tools=isToolRound?llmToolDefs:undefined)

if (response.toolCalls.length > 0 && round < MAX_ROUNDS) {
// Append assistant message with tool_use blocks
// Execute each tool via executeTool()
// Log to tool_invocations (fire-and-forget)
// Append user message with tool_result blocks
continue;
}

if (response.text) {
if (escalate && modelOverride) {
// Sonnet upgrade with 5s timeout
}
finalText = response.text; break;
}

// Empty text retry cascade:
// 1. Same model, clean messages (no tool artifacts), no tools
// 2. Escalate to Sonnet
// 3. Crisis hardcoded fallback OR friendly fallback
}

Tool execution (executeTool_executeToolInner):

  • switch statement dispatches to 39 implementations
  • Each returns a string result back to the LLM
  • Log to tool_invocations: church_id, tool_id, agent_type, persona_type, channel='chat', session_id (fire-and-forget)

Step 15: Safety Post-Processing

File: src/app/api/chatbot/stream/route.ts:1802-1867
Applies to: All paths (basic, pro_website, agentic)

Safety Pattern Regex (SAFETY_PATTERNS): A comprehensive regex (250+ chars) matching:

  • Direct terms: suicide, suicidal, kill myself, self-harm
  • Ideation phrases: "want to die", "don't want to be alive", "no reason to live"
  • Euphemisms: kms, unalive, sewerslide
  • Burden signals: "I'm just a burden", "no one would miss me"
  • Giving-away signals: "giving away my things", "won't need this anymore"
  • Religious euphemisms: "going home to the Lord", "ready to meet my maker"
  • Elderly euphemisms: "lived long enough", "ready to go"

Auto-flag (Step 15a):

  • If SAFETY_PATTERNS.test(message) AND flag_safety_concern was NOT in executedToolNames:
    • executeTool('flag_safety_concern', { level: 'urgent', description: '[AUTO-FLAGGED]...' }, toolContext)
    • INSERT to tool_invocations with agent_type: 'system_safety_net'
    • This is awaited (not fire-and-forget) — safety is blocking

Crisis resource injection (Step 15b):

  • If SAFETY_PATTERNS.test(message) AND (finalText missing 988 OR 741741 OR 911):
    • Append full crisis block to finalText
    • This runs AFTER crisis check — both LLM text and appended block get emoji-stripped

Emoji stripping:

  • stripEmoji(finalText) called unconditionally after crisis path
  • Strips: UTF-16 surrogate pairs (U+1F000+), BMP symbols U+2600-U+27BF, variation selectors
  • Rationale: emoji are inappropriate in life-safety contexts

Schedule hedging safety net:

  • If not crisis + response contains \d{1,2}:\d{2}\s*(AM|PM) + lacks hedging language:
    • Appends: "*(Schedules may change — we recommend confirming with the church office before your visit.)*"

HEAR impact: CRITICAL. The auto-flag and crisis resource injection are the last-resort safety net. They fire even if the LLM failed to call flag_safety_concern.
Regression risk: NEVER modify SAFETY_PATTERNS without running CI crisis keyword coverage checks. NEVER add exemptions without founder approval. These are life-safety code paths.


Step 16: Response Return + Post-Response Logging

File: src/app/api/chatbot/stream/route.ts:1869-1951

Usage tracking:

  • trackUsage({ churchId, sessionId, agentType, responseSource, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, model })
  • responseSource derived from model: llm_gemini | llm_openai | llm_sonnet | llm_haiku

Training log (fire-and-forget):

  • response_reviews INSERT: church_id, conversation_id, user_message, ai_response, agent_type, model, review_status='pending', detected_language
  • Language detection: keyword regex for Spanish (\b(hola|gracias|iglesia|...)\b), else 'en'

Conversation counts (fire-and-forget):

  • increment_conversation_counts RPC: p_conversation_id=sessionId, p_user_messages=1, p_bot_messages=1

Semantic cache store (fire-and-forget):

  • cacheResponse(churchId, message, queryEmbedding, finalText, model, agentType)
  • Skips if: response < 50 chars, or contains crisis keywords

Question analytics log (fire-and-forget):

  • logQuestion(churchId, message, agentType)chatbot_questions_log INSERT: organization_id, question_text (≤1000), question_normalized, category, asked_at

Latency tracking (fire-and-forget):

  • trackLatency({ churchId, sessionId, cascadeTier: 'llm_premium', totalMs, model, inputTokens, outputTokens, toolRounds })

HTTP response:

{
"response": "finalText",
"model": "claude-haiku-4-5-20251001",
"provider": "anthropic",
"rag": { "theological_hits": 3, "church_kb_hits": 2, "embedding_generated": true, "faq_matched": false }
}

Complete Tool Reference

Tool → DB Table Map

ToolDB Table WrittenNotes
submit_prayer_requestvoice_prayer_requestsDedup: skip if same church+text within 5 min
capture_visitor_contactvoice_visitor_contacts
request_callbackvoice_callback_requestsurgency: normal/urgent
book_appointmentvoice_callback_requestsCal.com link if configured; callback as fallback
get_church_directions(none — reads ctx.churchData)Returns Google Maps URL
get_first_visit_info(none — reads ctx.premiumData)
get_sermon_info(none — reads ctx.voiceAgentData)
get_announcements(none — reads ctx.voiceAgentData)
lookup_bible_verse(none)External: bible-api.com
send_connection_card_link(none — reads ctx.churchData)
find_small_group(none — reads ctx.premiumData.custom_ministries)
signup_for_volunteer_rolevoice_callback_requests
request_pastoral_visitvoice_callback_requestsurgency: normal/urgent/emergency
report_care_needvoice_callback_requests
start_visitor_followupvoice_visitor_contacts
conversation_summaryvoice_visitor_contactsstatus='resolved' if no followup
draft_follow_up_message(none — returns prompt for LLM)
subscribe_to_updatesvoice_visitor_contacts
send_message_to_staffvoice_callback_requestsReads custom_staff for matching
grief_support_resources(none — reads ctx.premiumData)Returns GriefShare/DivorceCare/Celebrate Recovery links
get_giving_historyvoice_visitor_contacts (optional log)Directs to church website
submit_benevolence_requestvoice_callback_requestsCONFIDENTIAL flag in reason
register_child_checkinvoice_visitor_contacts
detect_engagement_drop(reads) voice_visitor_contacts, voice_callback_requestsAdmin-only
generate_weekly_report(reads) voice_visitor_contacts, voice_prayer_requests, voice_callback_requestsAdmin-only
schedule_counselingvoice_callback_requestsCONFIDENTIAL; Cal.com if configured
daily_devotional(none — returns prompt for LLM)
facility_bookingvoice_callback_requests
get_kids_info(none — reads ctx.premiumData)
find_past_sermon(none — reads ctx.voiceAgentData)
get_worship_playlist(none — reads ctx.voiceAgentData)
send_giving_link(reads) church_voice_agentsReturns giving_url/etransfer_email
register_for_eventvoice_callback_requests
lookup_local_resources(reads) church_local_resources
search_illustrations(none)Calls sermonRAGSearchsearch_unified_rag_content RPC; returns IllustrateTheWord link if slug present
generate_devotional(none)Makes inner LLM call (Haiku); uses ragSearch
theological_deep_dive(none)Makes inner LLM call (Haiku); uses sermonRAGSearch
generate_lesson_plan(none)Makes inner LLM call (Haiku); uses ragSearch
flag_safety_concernvoice_callback_requests, moderation_violationsCalls autoEscalate() which may write user_restrictions

Admin Notification

Every tool that creates a DB record also calls notifyChurchAdmin(churchId, subject, summary):

  • Queries premium_churches.admin_email
  • Sends via Resend (hello@churchwiseai.comadmin_email)
  • Fire-and-forget; errors are swallowed

Complete Database Table Reference

TableOperationsColumns Used
premium_churchesSELECT (early validation + main load)church_id, custom_domain, id, status, plan, chatbot_enabled, custom_name, custom_hours, custom_staff, custom_ministries, what_to_expect, cap_info, admin_email
churchesSELECTid, name, denomination, address, phone, website, working_hours
church_voice_agentsSELECTpastor_name, callback_scheduling_enabled, cal_event_type_id, cal_api_key, pastor_availability_text, notification_email, sermon_topic, sermon_series, theme_verse, weekly_announcement, giving_enabled, giving_url, etransfer_email, giving_message
organization_settingsSELECTagent_tool_config, agent_config, chatbot_config, doctrinal_overrides
church_theological_lensesSELECTtheological_lens_id, sai_theological_lenses(lens_name)
sai_theological_lensesSELECTlens_id, lens_name
theological_contradictionsSELECTdoctrine_category, primary_position, contrary_positions, must_include_terms, must_exclude_terms, explanation
canned_responsesSELECT (cached 5 min)id, question, answer, agent_type, exact_response, category, denomination_pack, match_count
chatbot_conversationsUPSERTsession_id, organization_id, agent_type, persona_type, last_message_at
chatbot_questions_logINSERTorganization_id, question_text, question_normalized, category, asked_at
chatbot_response_cacheINSERT (write) + RPC (read via match_cached_response)church_id, query_text, query_embedding, response_text, response_model, agent_type, expires_at
product_knowledgeSELECT top 20category, question, answer, is_active, priority
church_local_resourcesSELECT (critical only for prompt injection; all for lookup_local_resources tool)name, category, phone, address, website, hours, notes, is_critical, is_active, sort_order
unified_rag_contentSELECT via search_unified_rag_content RPCTheological content + illustrations (327K rows — NEVER bulk delete)
church_knowledge_baseSELECT via search_church_knowledge RPCChurch FAQs + document chunks
voice_prayer_requestsINSERTchurch_id, caller_name, caller_phone, prayer_text, is_confidential, status
voice_visitor_contactsINSERT, SELECT (detect_engagement_drop)church_id, caller_name, caller_phone, caller_email, reason, status
voice_callback_requestsINSERT, SELECTchurch_id, caller_name, caller_phone, reason, urgency, status
tool_invocationsINSERTchurch_id, tool_id, agent_type, persona_type, channel, session_id
moderation_violationsINSERT, SELECT (count)church_id, session_id, user_identifier, violation_type, severity_score, detected_categories, original_message, action_taken
user_restrictionsSELECT, INSERTchurch_id, user_identifier, restriction_type, reason, expires_at
response_reviewsINSERTchurch_id, conversation_id, user_message, ai_response, agent_type, model, review_status, detected_language, tool_calls

Supabase RPCs called:

RPCPurposeTable Touched
increment_conversation_countsIncrement message counterschatbot_conversations
update_avg_response_timeUpdate running average latencychatbot_conversations
match_canned_responsesVector FAQ matchcanned_responses
increment_canned_match_countAnalytics countercanned_responses
search_unified_rag_contentTheological RAG searchunified_rag_content
search_church_knowledgeChurch KB searchchurch_knowledge_base
match_cached_responseSemantic cache lookupchatbot_response_cache
increment_chatbot_cache_hitCache analyticschatbot_response_cache

Third-Party API Reference

APIEndpointWhen CalledKey Used
OpenAI EmbeddingsPOST https://api.openai.com/v1/embeddingsFAQ Stage 2, Context Task 3OPENAI_API_KEY
OpenAI ModerationPOST https://api.openai.com/v1/moderationsDocument ingestion only (not per-message)OPENAI_API_KEY
Anthropic MessagesSDK client.messages.create()Main LLM (Haiku default, Sonnet escalated)ANTHROPIC_API_KEY
Gemini GeneratePOST https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContentTool-dispatch rounds when escalatedGEMINI_API_KEY
OpenAI ChatPOST https://api.openai.com/v1/chat/completionsFallback if Anthropic fails (5xx or timeout)OPENAI_API_KEY
Bible APIGET https://bible-api.com/{reference}?translation={t}lookup_bible_verse toolNone (free, no key)
ResendSDK resend.emails.send()Admin notifications (every tool write) + provider failure alertsRESEND_API_KEY
Cal.comhttps://cal.com/{calEventTypeId}book_appointment, schedule_counseling tools (URL only, no API call)None
botid/serverLocal checkBot detection at request entryNone

Environment Variables Required

VariableRequiredUsed For
ANTHROPIC_API_KEYYes (primary)Main LLM (Haiku, Sonnet)
OPENAI_API_KEYYes (for RAG + fallback)Embeddings + OpenAI fallback LLM
GEMINI_API_KEYOptionalTool-dispatch round optimization
RESEND_API_KEYYes (for notifications)Admin email notifications + LLM failover alerts
ALERT_EMAILOptionalLLM failover alert recipient (defaults to support@churchwiseai.com)
SUPABASE_SERVICE_ROLE_KEYYesAll Supabase queries
NEXT_PUBLIC_SUPABASE_URLYesSupabase connection
NODE_ENVAuto-setControls trusted origins (localhost in dev)

LLM Provider Details

Models

  • Primary default: claude-haiku-4-5-20251001
  • Escalated: claude-sonnet-4-6
  • Tool dispatch (escalated): gemini-2.5-flash-lite-preview-06-17
  • Fallback (Anthropic failure): gpt-4o-mini

Model Parameters by Agent Type

Configured in agent-prompts.ts via getModelParams(agentType):

  • Default: temperature: 0.7, max_tokens: 1024
  • Care/grief agents: lower temperature for consistency

Prompt Caching

  • Anthropic cache_control: { type: 'ephemeral' } on system prompt block
  • Saves ~60% on input token costs for multi-turn conversations
  • cacheReadTokens and cacheCreationTokens tracked separately in usage

Failover Behavior

  • 25-second timeout on Anthropic client
  • On Anthropic 5xx or timeout: auto-fallback to OpenAI gpt-4o-mini
  • Rate-limited email alert via Resend (1 per 15 min) to ops team
  • Tool-use results from Anthropic format → passed as user message in OpenAI fallback (format difference handled)

Chatbot Type Decision Tree

Request arrives
├── chatbotConfig.source === 'pewsearch_auto_provision'
│ └── Basic chatbot: 1 tool (prayer), 300 tokens, upsell CTA injected
├── rawPlan === 'pro_website' && NOT basic
│ └── Pro Website: 1 tool (prayer), 400 tokens, lensName theology, soft upgrade hint
└── All other plans (starter/pro/suite)
└── Agentic chatbot: 39 tools (tier-gated), 1024 tokens, full HEAR protocol

HEAR Protocol Implementation Points

The HEAR protocol (Hear, Empathize, Advance, Respond) is enforced at multiple levels:

LevelMechanism
System promptExplicit HEAR section with bad/good examples
PASTORAL_SKIP regexPrevents structured data from bypassing empathy
ACTION_SKIP regexPrevents structured data from short-cutting action requests
FAQ orderingFAQ (pastorally written) takes priority over raw structured data
Tool instructions"NEVER call submit_prayer_request as your FIRST action when someone is in distress"
Crisis escalation protocolLevel 4 (self-harm) → ALL THREE resources mandatory before anything else
Safety safety netAuto-appends crisis resources if LLM omits them
Emoji strippingRemoves inappropriate decoration from crisis responses

Moderation Escalation Ladder

Thresholds (per session):

  • 2 violations → 5-minute cooldown
  • 4 violations → 24-hour temp block
  • 7 violations → permanent block

Violation types: crisis | abuse_mild | abuse_severe | spam | predatory

What triggers logViolation + autoEscalate:

  • flag_safety_concern tool call (via LLM decision)
  • Auto-flag system safety net (when LLM missed crisis pattern)
  • Pro Website path: explicit crisis detection calls logViolation directly

Critical Safety Rules (Non-Negotiable)

  1. SAFETY_PATTERNS regex must always test the original message, not the LLM response. Users use coded language; the LLM may not catch it.
  2. Crisis resource block is always appended if 988/741741/911 are missing from the response. The LLM is not trusted to be 100% reliable on safety.
  3. flag_safety_concern is awaited (not fire-and-forget) on the auto-flag path. Safety writes must complete before returning.
  4. Emoji are always stripped from crisis responses (stripEmoji runs unconditionally on the crisis path).
  5. Crisis responses are never cachedCRISIS_KEYWORDS guard in cacheResponse prevents stale crisis responses from returning.
  6. The restriction system fails open — if the DB query fails, the session is NOT blocked. Better to allow a message than block someone in crisis.
  7. Pro Website moderation calls autoEscalate — crisis on pro_website plans still applies the escalation ladder.