Skip to main content

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_contacts row exists with vertical = 'church', status = 'provisioned'
  • preview_url populated on the outreach_contacts row
  • churches.email_do_not_contact = false
  • outreach_contacts.status != 'dnc' and status != 'bounced' and status != 'converted'
  • RESEND_API_KEY env var active
  • NEXT_PUBLIC_SITE_URL points 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.tschurches 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_url NOT 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.tsknowledge/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_at NOT 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.tspremium_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 — see feedback_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_at NOT 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' AND churches.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 = true OR outreach_contacts.status = 'dnc' — BOTH checked before every send. See unsubscribe-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

FailureSignalAlerting path
Scrape returns 0 churchesoutreach_contacts staleMorning brief P1
Provision fails (Haiku API error)status = 'provision_failed'Morning brief P1
FK constraint on tenant_voice_agentsProvision fails at step 5Logged in provision.ts — Stream A blocker
Resend bouncestatus = 'bounced', last_error populatedMorning brief + founder email
Send to DNC contactCRITICAL — email goes to person who unsubscribedAlert immediately, audit DNC gate
Cal.com booking not recordedstatus stays 'clicked' after bookingManual check in Cal.com + daily audit
Preview page 404Garbage collection removed demo rowCheck 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_at column existence — verify schema before using