Flow — Cold Outreach (Church)
Status: DRAFTED
Owner: john@churchwiseai.com
Last verified against prod: 2026-05-04 (initial draft — not yet run end-to-end)
Related skill: church-outreach
1. Purpose
Drive church prospects from first awareness to live demo to trial activation. Source: PewSearch database (218K+ visible churches). Target: pastors and church admins with no current AI tools. The pipeline scrapes, provisions a personalized preview, emails, and tracks engagement through a 4-email sequence ending in either booking, trial start, or DNC.
2. Trigger
Triggered by the weekly scrape cron or a manual founder action in the outreach engine dashboard.
- Type: cron (weekly) + manual override
- Source:
/api/cron/outreach-scrape/route.ts(scrape),/api/cron/outreach-send/route.ts(send) - Schedule: Scrape Mondays 6am ET; send Wednesdays 9am ET
- Founder UI:
/founder/[token]/outreach-engine
3. Preconditions
-
outreach_contactsrow exists withvertical = 'church',status = 'provisioned' -
preview_urlpopulated on theoutreach_contactsrow -
churches.email_do_not_contact = false -
outreach_contacts.status != 'dnc'andstatus != 'bounced'andstatus != 'converted' -
RESEND_API_KEYenv var active -
NEXT_PUBLIC_SITE_URLpoints to production (churchwiseai.com) - Physical address confirmed and in email footer template
4. Steps
Step 1 — Scrape
What happens: PewSearch database is queried for churches matching target criteria (email present, directory_visible=true, no existing premium_churches row, not previously contacted).
Where: /api/cron/outreach-scrape/route.ts → churches table SELECT
Verifications:
- db:
SELECT count(*) FROM outreach_contacts WHERE vertical='church' AND created_at >= now() - interval '7 days'→ expect >0 after Monday cron - code: Check
outreach_contacts.source='church_pewsearch_scrape' - db:
SELECT id FROM churches WHERE email_do_not_contact = true→ confirm excluded
Failure signal: outreach_contacts count doesn't grow after Monday cron. Check cron log at /api/cron/outreach-scrape — 500 means DB issue; 200 with count=0 means filter too restrictive.
Step 2 — Provision (Personalized Preview)
What happens: For each new outreach_contacts row, provision.ts scrapes the church website, generates per-prospect Q&A via Haiku 4.5 API, creates a premium_churches demo row, and writes preview_url back to outreach_contacts.
Where: churchwiseai-web/src/lib/outreach/provision.ts (full 9-step pipeline)
Verifications:
- db:
SELECT preview_url, status FROM outreach_contacts WHERE id = '<prospect_id>'→preview_urlNOT NULL,status = 'provisioned' - render: GET
{preview_url}→ HTTP 200, page title contains church name - db:
SELECT id FROM premium_churches WHERE admin_token LIKE 'demo-%'→ confirm demo row exists for this prospect
Failure signal: outreach_contacts.status = 'provision_failed'. Root cause: Haiku API error, church website blocked scraping, or FK constraint on tenant_voice_agents (see Stream A blocker).
Step 3 — Send Email 1 (Day 0)
What happens: The outreach send cron picks up status = 'provisioned' rows and sends Email 1 via Resend. Status updates to 'email_1_sent'.
Where: /api/cron/outreach-send/route.ts → knowledge/narrative/cold-outreach-emails-church.md templates
Verifications:
- db:
SELECT status, email_1_sent_at FROM outreach_contacts WHERE id = '<prospect_id>'→status = 'email_1_sent',email_1_sent_atNOT NULL - delivered: Gmail search
to:<prospect_email> subject:"One question about"within 15 min of cron → message present - render: Verify email body contains correct
preview_url,[Church Name], CASL footer with physical address and unsubscribe link
Failure signal: Resend delivery failure logged in outreach_contacts.last_error. Bounce code 550 = invalid email → set status = 'bounced'. Bounce code 421 = temp → retry next cron.
Step 4 — Click → Preview Page
What happens: Prospect clicks the demo link in Email 1. Short-link resolver at /p/[token] stamps pro_website_clicked_at on outreach_contacts and 302s to /preview/[slug]?ref=outreach_{token}.
Where: churchwiseai-web/src/app/p/[token]/route.ts
Verifications:
- db: After clicking:
SELECT pro_website_clicked_at FROM outreach_contacts WHERE id = '<prospect_id>'→ NOT NULL - db:
outreach_contacts.status→ updated to'clicked' - render: Preview page loads at
/preview/[slug]with: church name in hero, denomination branding, demo chatbot widget visible
Failure signal: pro_website_clicked_at remains NULL after click → short-link resolver broken. Preview 404 → demo row was garbage-collected or slug mismatch.
Step 5 — Chatbot Interaction (Demo)
What happens: Prospect types into the demo chatbot on the preview page. The chatbot hits /api/chatbot/stream with the demo church_id, uses the per-prospect knowledge base, and responds in the church's voice.
Where: churchwiseai-web/src/app/api/chatbot/stream/route.ts → premium_churches demo row
Verifications:
- render: Type "What are your service times?" → chatbot responds with correct service time (from provisioned Q&A)
- render: Type "I need prayer" → HEAR protocol activates, care agent responds appropriately
- db:
SELECT count(*) FROM chatbot_conversations WHERE church_id = '<demo_church_id>'→ count increments after interaction
Failure signal: Chatbot returns 400 or "I don't have that information" on a question the preview was provisioned to answer → provision.ts Q&A generation failed or knowledge base not wired correctly.
Step 6 — Demo Voice Call (optional — prospect dials demo number)
What happens: Preview page shows a demo phone number and 4-character code. Prospect calls, enters code, reaches the demo voice agent with per-prospect knowledge loaded.
Where: churchwiseai-web/voice-agent-livekit/session.py → PHONE_REGISTRY → church_voice_agents demo row
Verifications:
- code: Call connects and voice agent greets prospect with church name (not "ChurchWiseAI demo")
- db:
SELECT count(*) FROM voice_call_logs WHERE church_id = '<demo_church_id>'→ new row after call - code: 555-prefix demo numbers (
+1[area]555xxxx) are NEVER shown — seefeedback_555_phone_numbers_are_fake.md
Failure signal: Voice agent greets with wrong name → PHONE_REGISTRY mapping stale. No log in voice_call_logs → call didn't reach LiveKit.
Step 7 — Email 2 (Day 3) and Email 3 (Day 7)
What happens: Follow-up cron checks email_1_sent_at and sends Email 2 at +3 days, Email 3 at +7 days. Both skip if outreach_contacts.status = 'dnc', 'bounced', or 'converted'.
Where: /api/cron/outreach-followup/route.ts
Verifications:
- db:
SELECT status, email_2_sent_at, email_3_sent_at FROM outreach_contacts WHERE id = '<prospect_id>' - delivered: Gmail search for Email 2 subject "The Friday night call" at day 3 → present
- code: DNC gate: if
churches.email_do_not_contact = true, neither email sends → verify with test row
Failure signal: Follow-up emails not sending → check cron schedule in Vercel cron config. Emails sending to DNC contacts → DNC gate broken, CRITICAL.
Step 8 — Reply / Book / Convert
What happens: Prospect replies to an email, clicks the Cal.com link, or clicks the "Start Trial" CTA on the preview page. Reply is detected via Gmail poll cron. Cal.com booking fires a webhook that creates a calendar_events row.
Where: /api/cron/gmail-poll/route.ts (reply detection), Cal.com webhook → outreach_contacts.status = 'booked'
Verifications:
- db: After booking:
SELECT status, booked_at FROM outreach_contacts WHERE id = '<prospect_id>'→status = 'booked',booked_atNOT NULL - db: After trial start:
SELECT id FROM premium_churches WHERE admin_token NOT LIKE 'demo-%'→ new real row if they onboarded
Failure signal: Booking not recorded → Cal.com webhook not configured for church event type. Trial not activating → Stripe webhook inbox processing failure.
Step 9 — Email 4 Break-Up (Day 14)
What happens: At day 14 with no click/reply/booking, send Email 4 (break-up). If they reply "not a fit," set status = 'dnc'.
Where: /api/cron/outreach-followup/route.ts
Verifications:
- db:
SELECT email_4_sent_at FROM outreach_contacts WHERE id = '<prospect_id>'→ NOT NULL at day 14 - code: "not a fit" reply →
outreach_contacts.status = 'dnc'ANDchurches.email_do_not_contact = true
Failure signal: Email 4 not sending → check day-14 condition in followup cron. "not a fit" reply not processed → Gmail poll regex not matching.
5. What the recipient sees
Email 1: Subject: "One question about [Church Name]"
- From: john@churchwiseai.com
- Opens with one specific observation about their church
- Soft CTA: "[VIEW [CHURCH NAME]'S DEMO →]" — a link to the personalized preview
- Footer: CASL-compliant with physical address and unsubscribe link
Preview page: /preview/[slug] — church-branded, demo chatbot, demo voice number, "Start Free Trial" CTA to /onboard
6. Compliance & Unsubscribe
- Regime: CASL (primary — Canadian prospects) + CAN-SPAM (US prospects)
- Unsubscribe link location: Every email footer →
churchwiseai.com/unsubscribe?token={{token}} - DNC gate:
churches.email_do_not_contact = trueORoutreach_contacts.status = 'dnc'— BOTH checked before every send. Seeunsubscribe-and-dnc-gating.md. - Physical address footer: Required. Must be confirmed with founder before first send.
- Source disclosure: "You're receiving this because [Church Name] appears in the PewSearch church directory at pewsearch.com"
7. Failure Modes
| Failure | Signal | Alerting path |
|---|---|---|
| Scrape returns 0 churches | outreach_contacts stale | Morning brief P1 |
| Provision fails (Haiku API error) | status = 'provision_failed' | Morning brief P1 |
FK constraint on tenant_voice_agents | Provision fails at step 5 | Logged in provision.ts — Stream A blocker |
| Resend bounce | status = 'bounced', last_error populated | Morning brief + founder email |
| Send to DNC contact | CRITICAL — email goes to person who unsubscribed | Alert immediately, audit DNC gate |
| Cal.com booking not recorded | status stays 'clicked' after booking | Manual check in Cal.com + daily audit |
| Preview page 404 | Garbage collection removed demo row | Check 30-day TTL logic in cron |
8. Verification Manifest
flow: cold-outreach-church
verifications:
- step: 1
verb: db
command: SELECT count(*) FROM outreach_contacts WHERE vertical='church' AND created_at >= now() - interval '7 days'
expect: ">0 after Monday cron"
- step: 2
verb: db
command: SELECT preview_url, status FROM outreach_contacts WHERE id = '<id>'
expect: "preview_url NOT NULL, status = provisioned"
- step: 3
verb: db
command: SELECT status, email_1_sent_at FROM outreach_contacts WHERE id = '<id>'
expect: "email_1_sent, email_1_sent_at NOT NULL"
- step: 4
verb: db
command: SELECT pro_website_clicked_at FROM outreach_contacts WHERE id = '<id>'
expect: "NOT NULL after click"
- step: 5
verb: render
command: "GET /preview/[slug] → type 'What are your service times?'"
expect: "Correct service times in chatbot response"
- step: 7
verb: db
command: SELECT email_2_sent_at, email_3_sent_at FROM outreach_contacts WHERE id = '<id>'
expect: "NOT NULL at day 3 and day 7 respectively"
- step: 9
verb: db
command: SELECT email_4_sent_at FROM outreach_contacts WHERE id = '<id>'
expect: "NOT NULL at day 14 if no reply/booking"
9. Open Questions / Known Gaps
- Physical address for CASL footer not yet confirmed — BLOCKER for first send
- Cal.com webhook integration for church event type not yet verified — needs testing before first campaign
- 555-prefix phone number guard in demo provisioning — verify it rejects fake numbers at provision time
-
outreach_contacts.email_4_sent_atcolumn existence — verify schema before using