NOTE: The legacy
/api/chatbot/chatendpoint 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/streampipeline — 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 fromx-forwarded-for,x-real-ip, or fallbacklimiter(ip)checks in-memory token bucket; returns{ success: boolean }- If
!success→getRateLimitResponse()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()frombotid/serverpackage - 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
requestOriginfromOriginheader, falling back toRefererURL origin isTrustedOrigin= boolean (does NOT block; logs only)- Early church validation: Queries
premium_churchesforchurch_id + chatbot_enabled=true + status IN ('active','preview')— returns 404 if not found. This prevents arbitrarychurchIdabuse that would consume resources. - If suspicious origin and not the church's
custom_domain→console.warn
Database operations:
premium_churchesSELECT:church_id, custom_domainWHEREchurch_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_restrictionsfor 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_restrictionsSELECT:id, restriction_type, reason, expires_atWHEREchurch_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_packWHEREchurch_id = $1 AND is_active = trueORDER BYmatch_count DESCLIMIT 500 - Pre-computes
questionNormalized(lowercase, strip punctuation) andquestionTokens(Set of non-stopword tokens) for each response
Text match algorithm:
- Exact match:
questionNormalized === normalize(message)→ similarity = 1.0 - Jaccard similarity:
|A ∩ B| / |A ∪ B|on token sets → must exceed 0.75 to match
Decision tree:
exactResponse=true AND similarity ≥ 0.9→ short-circuit: return answer directly (zero LLM cost), track ascanned, increment conversation counts- Fuzzy match → inject as
preferredContextfor 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_responsesRPC: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_responsesSELECT (with 5-min cache)match_canned_responsesRPC (vector match, Stage 2 only)increment_canned_match_countRPC (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_enabledis 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 tostarter | 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 withchatbotDefault: true) - Override with
orgSettings.agent_tool_config.chatbot_toolsif 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 toolisProWebsite: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_concernpro(+ 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_plansuite(+ 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):
- Demo override:
lensOverrideparam is a valid ID (1-17) +lensNameOverrideis non-empty ≤ 50 chars → use directly (client selected tradition in demo mode). Name is sanitized:replace(/[^\w\s/()'-]/g, '').slice(0, 50) - DB setting: Query
church_theological_lenses JOIN sai_theological_lensesfor this church → use storedtheological_lens_id - Denomination auto-detect: Look up
church.denominationinDENOMINATION_TO_LENSmap (40+ denomination strings mapped to 17 lens IDs) → querysai_theological_lensesfor lens name - Default: lensId = 10 (Christocentric), lensName = 'Christocentric'
Database operations (when not demo override):
church_theological_lensesJOINsai_theological_lenses:SELECT theological_lens_id, sai_theological_lenses(lens_name)WHEREchurch_id = $1sai_theological_lenses:SELECT lens_nameWHERElens_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)\bPASTORAL_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.faqPreferredContextis set (FAQ fuzzy match found) → skip structured data entirely; FAQ content takes priority for pastoral empathy
Pattern matching (first match wins):
| Pattern | Returns |
|---|---|
| service/worship time | Formatted hours with hedging disclaimer |
| address/location | Address + "We'd love to see you!" |
| phone number | Phone number |
| website/url | Website URL |
| pastor/staff | Staff list |
| denomination | Denomination name |
| wear/dress code | whatToExpect.dress_code |
| parking | whatToExpect.parking |
| kid/child/nursery/baby | whatToExpect.children |
| youth/teen | Youth ministry + children info |
| first visit/what to expect | Full whatToExpect block |
| music style | whatToExpect.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_contradictionsWHERElens_id = $1ORDER BYdoctrine_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_overridesfor custom church overrides (JSONB) - If church has custom practices: appends "CUSTOM CHURCH PRACTICES:" block
Task 2 — Lens Vocabulary:
- Calls
fetchLensVocabulary(lensId)→ querieslens_knowledgetable buildChatVocabularyBlock(lensVocab, lensName)→ produces text block with preferred/avoided terminology for this tradition
Task 3 — RAG Retrieval:
- Calls
generateEmbedding(message)→ OpenAItext-embedding-3-smallAPI → 1536-dim vector - Then runs TWO searches in parallel:
searchRAG({ queryEmbedding, lensIds: [lensId], matchCount: 8 })→search_unified_rag_contentRPC, threshold 0.35searchChurchKnowledge(churchId, embedding)→search_church_knowledgeRPC, threshold 0.35, matchCount 8
formatRAGContext(results)→ numbered list, each item: title + type + 600-char snippetformatChurchKnowledgeContext(results)→ numbered list with FAQ/Document label + 600-char snippet- Stores
queryEmbeddingandchurchKnowledgeResultsfor cascade use
Task 4 — Product Knowledge:
- Queries
product_knowledgeWHEREis_active = trueORDER BYpriority DESCLIMIT 20 - Returns
category, question, answerfor top 20 entries - Formats as:
Q: ...\nA: ...block injected into system prompt
Database operations:
theological_contradictionsSELECT (lens-filtered)organization_settingsSELECTdoctrinal_overrides(church-specific)lens_knowledgeSELECT (viafetchLensVocabulary)search_unified_rag_contentRPC (Postgres pgvector cosine similarity onunified_rag_content)search_church_knowledgeRPC (Postgres pgvector onchurch_knowledge_base)product_knowledgeSELECT 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_responseRPC - 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 churchinformation into a natural, conversational response. Keep it to 1-2 sentences."
- maxTokens: 200, temperature: 0.3
- On success: caches response (
cacheResponse), tracks usage asllm_haiku
Database operations:
match_cached_responseRPC (pgvector cosine onchatbot_response_cache)increment_chatbot_cache_hitRPC (fire-and-forget)chatbot_response_cacheINSERT (viacacheResponse, 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:
- Build
basicMessagesfrom history (last 10) + current message - Call
callLLM()withprayerToolSchema - If tool called →
executeTool('submit_prayer_request', args, ctx)→ insertvoice_prayer_requests - Log tool to
tool_invocations - Follow-up call (no tools) to get final text
- 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_countsRPCupdate_avg_response_timeRPCcacheResponse()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_reviewsINSERT (every response logged for admin review)- Returns
upgradeUrl: 'https://churchwiseai.com/pricing'andupgradeMessagein 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_resourcesWHEREchurch_id = $1 AND is_active = true AND is_critical = trueLIMIT 10 - Appended as: "LOCAL EMERGENCY / CRISIS RESOURCES (provided by {churchName}):"
- CAP info from
premium_churches.cap_infoalso injected
Layer 4 — Agent specialization:
buildAgentSystemPrompt(agentType, enabledChatbotTools, personalityOverrides, handoffRules)- Appended only if
agentTypeis set (persona-specific instructions)
Layer 5 — Care library (if escalated):
detectCareIntent(message, history)→ identifies grief/crisis categoryloadCareLibraryContext(category, subcategory, denomination)→ fetches CPE-quality pre-built pastoral responsesbuildCareSystemPrompt(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)ANDflag_safety_concernwas NOT inexecutedToolNames:executeTool('flag_safety_concern', { level: 'urgent', description: '[AUTO-FLAGGED]...' }, toolContext)- INSERT to
tool_invocationswithagent_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
- Append full crisis block to
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.)*"
- Appends:
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 })responseSourcederived from model:llm_gemini | llm_openai | llm_sonnet | llm_haiku
Training log (fire-and-forget):
response_reviewsINSERT: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_countsRPC: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_logINSERT: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
| Tool | DB Table Written | Notes |
|---|---|---|
submit_prayer_request | voice_prayer_requests | Dedup: skip if same church+text within 5 min |
capture_visitor_contact | voice_visitor_contacts | |
request_callback | voice_callback_requests | urgency: normal/urgent |
book_appointment | voice_callback_requests | Cal.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_role | voice_callback_requests | |
request_pastoral_visit | voice_callback_requests | urgency: normal/urgent/emergency |
report_care_need | voice_callback_requests | |
start_visitor_followup | voice_visitor_contacts | |
conversation_summary | voice_visitor_contacts | status='resolved' if no followup |
draft_follow_up_message | (none — returns prompt for LLM) | |
subscribe_to_updates | voice_visitor_contacts | |
send_message_to_staff | voice_callback_requests | Reads custom_staff for matching |
grief_support_resources | (none — reads ctx.premiumData) | Returns GriefShare/DivorceCare/Celebrate Recovery links |
get_giving_history | voice_visitor_contacts (optional log) | Directs to church website |
submit_benevolence_request | voice_callback_requests | CONFIDENTIAL flag in reason |
register_child_checkin | voice_visitor_contacts | |
detect_engagement_drop | (reads) voice_visitor_contacts, voice_callback_requests | Admin-only |
generate_weekly_report | (reads) voice_visitor_contacts, voice_prayer_requests, voice_callback_requests | Admin-only |
schedule_counseling | voice_callback_requests | CONFIDENTIAL; Cal.com if configured |
daily_devotional | (none — returns prompt for LLM) | |
facility_booking | voice_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_agents | Returns giving_url/etransfer_email |
register_for_event | voice_callback_requests | |
lookup_local_resources | (reads) church_local_resources | |
search_illustrations | (none) | Calls sermonRAGSearch → search_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_concern | voice_callback_requests, moderation_violations | Calls 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.com→admin_email) - Fire-and-forget; errors are swallowed
Complete Database Table Reference
| Table | Operations | Columns Used |
|---|---|---|
premium_churches | SELECT (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 |
churches | SELECT | id, name, denomination, address, phone, website, working_hours |
church_voice_agents | SELECT | 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 |
organization_settings | SELECT | agent_tool_config, agent_config, chatbot_config, doctrinal_overrides |
church_theological_lenses | SELECT | theological_lens_id, sai_theological_lenses(lens_name) |
sai_theological_lenses | SELECT | lens_id, lens_name |
theological_contradictions | SELECT | doctrine_category, primary_position, contrary_positions, must_include_terms, must_exclude_terms, explanation |
canned_responses | SELECT (cached 5 min) | id, question, answer, agent_type, exact_response, category, denomination_pack, match_count |
chatbot_conversations | UPSERT | session_id, organization_id, agent_type, persona_type, last_message_at |
chatbot_questions_log | INSERT | organization_id, question_text, question_normalized, category, asked_at |
chatbot_response_cache | INSERT (write) + RPC (read via match_cached_response) | church_id, query_text, query_embedding, response_text, response_model, agent_type, expires_at |
product_knowledge | SELECT top 20 | category, question, answer, is_active, priority |
church_local_resources | SELECT (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_content | SELECT via search_unified_rag_content RPC | Theological content + illustrations (327K rows — NEVER bulk delete) |
church_knowledge_base | SELECT via search_church_knowledge RPC | Church FAQs + document chunks |
voice_prayer_requests | INSERT | church_id, caller_name, caller_phone, prayer_text, is_confidential, status |
voice_visitor_contacts | INSERT, SELECT (detect_engagement_drop) | church_id, caller_name, caller_phone, caller_email, reason, status |
voice_callback_requests | INSERT, SELECT | church_id, caller_name, caller_phone, reason, urgency, status |
tool_invocations | INSERT | church_id, tool_id, agent_type, persona_type, channel, session_id |
moderation_violations | INSERT, SELECT (count) | church_id, session_id, user_identifier, violation_type, severity_score, detected_categories, original_message, action_taken |
user_restrictions | SELECT, INSERT | church_id, user_identifier, restriction_type, reason, expires_at |
response_reviews | INSERT | church_id, conversation_id, user_message, ai_response, agent_type, model, review_status, detected_language, tool_calls |
Supabase RPCs called:
| RPC | Purpose | Table Touched |
|---|---|---|
increment_conversation_counts | Increment message counters | chatbot_conversations |
update_avg_response_time | Update running average latency | chatbot_conversations |
match_canned_responses | Vector FAQ match | canned_responses |
increment_canned_match_count | Analytics counter | canned_responses |
search_unified_rag_content | Theological RAG search | unified_rag_content |
search_church_knowledge | Church KB search | church_knowledge_base |
match_cached_response | Semantic cache lookup | chatbot_response_cache |
increment_chatbot_cache_hit | Cache analytics | chatbot_response_cache |
Third-Party API Reference
| API | Endpoint | When Called | Key Used |
|---|---|---|---|
| OpenAI Embeddings | POST https://api.openai.com/v1/embeddings | FAQ Stage 2, Context Task 3 | OPENAI_API_KEY |
| OpenAI Moderation | POST https://api.openai.com/v1/moderations | Document ingestion only (not per-message) | OPENAI_API_KEY |
| Anthropic Messages | SDK client.messages.create() | Main LLM (Haiku default, Sonnet escalated) | ANTHROPIC_API_KEY |
| Gemini Generate | POST https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent | Tool-dispatch rounds when escalated | GEMINI_API_KEY |
| OpenAI Chat | POST https://api.openai.com/v1/chat/completions | Fallback if Anthropic fails (5xx or timeout) | OPENAI_API_KEY |
| Bible API | GET https://bible-api.com/{reference}?translation={t} | lookup_bible_verse tool | None (free, no key) |
| Resend | SDK resend.emails.send() | Admin notifications (every tool write) + provider failure alerts | RESEND_API_KEY |
| Cal.com | https://cal.com/{calEventTypeId} | book_appointment, schedule_counseling tools (URL only, no API call) | None |
| botid/server | Local check | Bot detection at request entry | None |
Environment Variables Required
| Variable | Required | Used For |
|---|---|---|
ANTHROPIC_API_KEY | Yes (primary) | Main LLM (Haiku, Sonnet) |
OPENAI_API_KEY | Yes (for RAG + fallback) | Embeddings + OpenAI fallback LLM |
GEMINI_API_KEY | Optional | Tool-dispatch round optimization |
RESEND_API_KEY | Yes (for notifications) | Admin email notifications + LLM failover alerts |
ALERT_EMAIL | Optional | LLM failover alert recipient (defaults to support@churchwiseai.com) |
SUPABASE_SERVICE_ROLE_KEY | Yes | All Supabase queries |
NEXT_PUBLIC_SUPABASE_URL | Yes | Supabase connection |
NODE_ENV | Auto-set | Controls 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
cacheReadTokensandcacheCreationTokenstracked 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:
| Level | Mechanism |
|---|---|
| System prompt | Explicit HEAR section with bad/good examples |
| PASTORAL_SKIP regex | Prevents structured data from bypassing empathy |
| ACTION_SKIP regex | Prevents structured data from short-cutting action requests |
| FAQ ordering | FAQ (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 protocol | Level 4 (self-harm) → ALL THREE resources mandatory before anything else |
| Safety safety net | Auto-appends crisis resources if LLM omits them |
| Emoji stripping | Removes 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_concerntool 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)
- SAFETY_PATTERNS regex must always test the original
message, not the LLM response. Users use coded language; the LLM may not catch it. - 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.
flag_safety_concernis awaited (not fire-and-forget) on the auto-flag path. Safety writes must complete before returning.- Emoji are always stripped from crisis responses (
stripEmojiruns unconditionally on the crisis path). - Crisis responses are never cached —
CRISIS_KEYWORDSguard incacheResponseprevents stale crisis responses from returning. - 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.
- Pro Website moderation calls
autoEscalate— crisis on pro_website plans still applies the escalation ladder.