All posts
EngineeringJune 11, 202617 min read

Customer Support X-ray - The Outreach Strategy

Instead of waiting for store owners to find the tool, this system pre-scans their store, then emails them a link to their own AI-written support teardown - a closed loop that sources, qualifies, analyzes, and converts, with every move logged to a team dashboard.

The tool was already good: paste any store's URL and get a grounded, AI-written teardown of how an agent would handle its top support tickets. The missing half was distribution - getting that teardown in front of the right store owners. This is the build story of the outreach strategy: a closed loop that sources stores, qualifies them cheaply, runs the full teardown only on the keepers, emails each owner a personalized card linking to their own result, and converts the click on-site - with every move logged to a team dashboard.

Stack: Next.js 16 (App Router) · TypeScript · Tailwind v4 / shadcn · Anthropic Claude (claude-sonnet-4-6) · Supabase (Postgres + Auth + RLS) · Python tools · Resend (transactional + cold email) · Vercel.


The Thesis: Don't Wait for Store Owners - Bring Them Their Own Teardown

The naive growth motion for a free tool is inbound: publish it and hope owners come paste their URL. That works for a trickle. The sharper motion is the reverse:

Pre-scan the store, then email the owner a link to their own teardown.

A cold email that says "AI could help your support" is noise. A cold email that says "I ran a teardown on your store - ~70% of your tickets look automatable, here's the read" is relevance. The teardown is the personalization. Seeing your own store's number is the hook, and the link lands on a page that's already about you.

So the strategy is outbound in delivery but inbound in quality: every email is backed by a real, grounded analysis of that specific store.


The Loop

  seeds / store-DB export / competitor reviews        ← sourcing
              │
              ▼
      find_prospects.py   ── cheap ICP scoring, no LLM ──►  prospects.csv (+ targets.csv)
              │
              ▼
      batch_xray.py       ── full Claude teardown + email harvest ──►  Supabase `teardowns`
              │
              ▼
      outreach.py         ── personalized HTML card via Resend ──►  owner's inbox
              │                                                         │
              │  links to /x/<domain>  ◄───────────────────────────────┘
              ▼
   teardown page → "Find out more" form → /api/enroll → `enrollments` + auto follow-up email
              │
              ▼
   team dashboard:  Outreach · Enrollments · Activity (run logs)

Each stage exists for one reason:

  • Sourcing - get candidate domains.
  • find_prospects.py - throw away bad fits before spending any Claude money.
  • batch_xray.py - produce the teardown (the personalization) and grab a contact email for free.
  • outreach.py - deliver the teardown as a cold email that earns the click.
  • The on-site funnel - convert the click into a captured, replied-to lead.
  • The dashboard - see outcomes and the play-by-play in one place.

Sourcing: Three Ways to Fill the Top of the Funnel

The pipeline qualifies and analyzes - it doesn't conjure domains. You feed it candidates from one of three sources, depending on whether you want free in-market, raw volume, or a targeted niche:

  1. Niche seeds - a hand-curated CSV of real DTC brands in a vertical. Cheapest to start; great for a focused campaign. Example: tools/seeds/supplements.csv (30 supplement/wellness brands), alongside the existing food-beverage.csv and pet.csv. Format is just one column:
  domain
  ritual.com
  seed.com
  livemomentous.com
  1. Store-database export (Storeleads / BuiltWith / Wappalyzer) - the volume play. Filter the export to your ICP (category, technologies, traffic rank, geo) and include the technologies and emails columns. find_prospects.py then qualifies thousands of rows with no fetching and gets emails for free.
  2. Competitor reviews - tools/reviews_shopify.py mines public Shopify App Store reviews of rival support tools (Gorgias, Zendesk, Re:amaze…) and pulls out the unhappy stores - shops that are already paying for support software and frustrated. Highest-signal, in-market, and free.

Why ICP-filter at the source? Every domain you let through costs a Claude call at the teardown stage. The cheapest filter is the one you apply before you ever fetch a page.

Deep-Dive: Mining Competitor Reviews - the Warmest Cold Leads

Of the three sources, competitor reviews are the highest-signal, and they're worth their own pass because the qualification is baked into the source. tools/reviews_shopify.py walks the public Shopify App Store review pages of rival support apps and keeps only the reviews that signal pain:

COMPETITORS = [
    ("helpdesk", "Gorgias"), ("reamaze", "Re:amaze"), ("tidio-chat", "Tidio"),
    ("zendesk", "Zendesk"), ("help-scout", "Help Scout"), ("chatra", "Chatra"),
    ("chatway", "Chatway"),
]
PAIN_KEYWORDS = ["expensive", "overpriced", "price increase", "billing", "switching",
                 "alternative", "buggy", "broken", "refund", "cancel", "slow", …]

A review is kept if its rating is ≤ 3 stars or its text contains a pain keyword. Each kept review names the reviewing store - and that name is the lead.

Why this is the warmest possible cold lead. A 2★ review of Gorgias that says "support got worse and the price keeps climbing" tells you four things at once about that store: it has enough ticket volume to need a helpdesk, it already pays for one (budget), it's actively unhappy (timing), and you know exactly which competitor to displace. A seed list gives you fit; a competitor review gives you fit plus intent plus timing. That's a different quality of lead.

From a review to a domain. The store's display name isn't a domain, so --resolve-domains does a best-effort map - try {name}.com and {name}.myshopify.com, fetch it, and keep it only if the page actually looks like Shopify - then writes the resolved leads to tools/review_leads.csv:

domain,merchant,competitor,rating
somebrand.com,Some Brand,Gorgias,2

Every hit is also upserted into Supabase (reddit_hits, tagged subreddit='Reviews') so it shows in the dashboard's Reviews tab with a one-click "Run X-ray → {domain}" button.

The full cycle, end to end (run locally, from tools/):

# 1) discover unhappy stores + resolve domains  -> review_leads.csv (+ Reviews tab)
.venv/bin/python reviews_shopify.py --pages 3 --resolve-domains
# 2) x-ray each resolved store (+ harvest a contact email during the crawl)
.venv/bin/python batch_xray.py --input review_leads.csv
# 3) email-identify = the harvest; send to good-fit stores with a valid address
.venv/bin/python outreach.py --input review_leads.csv -v          # dry run first
.venv/bin/python outreach.py --input review_leads.csv --send --limit 5

No glue code was needed for steps 2 - 3: review_leads.csv carries a domain column that batch_xray and outreach already read, and because it has no email column, outreach's resolve_email falls straight through to the harvested contact_emails (validated by emailutil, see the "Walls" section below).

Walls in the reviews path (and how we routed around them). Two honest limits. First, name → domain resolution is lossy - only the clean {name}.com cases resolve, so review_leads.csv is a smaller, higher-signal subset of the reviews found; the rest wait in the Reviews tab for a human. Second, the script was written before the Python-3.9 and tools/-path fixes the rest of the pipeline got, so it crashed at import on stock macOS Python and wrote its CSV to the wrong directory - both fixed by mirroring the same from __future__ import annotations + TOOLS_DIR patterns. The lesson that kept recurring: a tool is only "done" once it runs from the same directory, on the same Python, as everything it feeds.

Read/analyze-only, and polite. Like every collector in this project, the miner only reads public pages (a star rating and a review body anyone can see), with a page cap and a delay between requests. It never posts, replies, or contacts anyone - the only outreach in the whole system is the one email outreach.py sends, to a business support inbox, with an unsubscribe line.


Qualify Cheaply - find_prospects.py (No LLM Spend)

The first script scores each candidate for ICP fit using only its tech stack - no Claude calls. The thesis: a Shopify store running Recharge (subscriptions → recurring "where's my order" and subscription-change tickets) and Gorgias (an existing helpdesk → real ticket volume and budget) is a bullseye. The scoring weights encode exactly that:

signalcategorypointswhy
Shopify / Woo / BigCommerceecommerce+2it's a real store
Recharge / Skio / Boldsubscriptions+3recurring orders → WISMO + sub-change tickets
Gorgias / Zendesk / Intercomhelpdesk+2existing ticket volume + budget
Klaviyo / Omnisendemail+1marketing maturity
Loop / Returnly / AfterShipreturns+1returns volume

Two modes, picked automatically: if the input CSV has a technologies column (a store-DB export), it scores straight from that - instant, thousands of rows. Otherwise it live-fingerprints each homepage (one cheap GET per row).

.venv/bin/python find_prospects.py --input seeds/supplements.csv --min-score 5 --write-targets 20

--min-score drops weak fits; --write-targets N promotes the top N to targets.csv (the input to the teardown step). Real output from the supplements seed - almost the whole list is bullseye:

domain                score  shopify  stack
ritual.com            8      True     Shopify|Recharge|Intercom|Klaviyo
1stphorm.com          8      True     Shopify|Recharge|Gorgias|Klaviyo
gainful.com           8      True     Shopify|Recharge|Skio|Intercom|Klaviyo
vegamour.com          8      True     Shopify|Recharge|Gorgias|Klaviyo
…
ancientnutrition.com  5      False    Recharge|Skio|Gorgias

Why score before analyzing? You can qualify thousands of candidates for the price of a few HTTP requests, then spend Claude only on the top N. The funnel math is: source infinitely → qualify for free → analyze the keepers → email the good fits. Cost is controlled by how many you promote, not by how many you source.


The Teardown - batch_xray.py (the Personalization, Plus a Free Email)

batch_xray.py runs the same Claude analysis as the public web tool (identical system prompt and JSON schema), crawls each store's public support pages, and upserts the full result into the Supabase teardowns table with source='batch'. That stored result is what powers both the dashboard card and the /x/<domain> page the cold email links to.

.venv/bin/python batch_xray.py --input targets.csv          # add -v to watch every step

It pulls out, per store: brand, automatable_pct, top_ticket (with a today vs automated contrast), a cost model (agent_cost, ai_cost), and coverage_gap_hours.

The quiet but important move: while it's already crawling the store's pages, it harvests contact emails (mailto: links and footer/contact-page addresses) and stores them on the teardown as contact_emails. The crawl is paid for either way - harvesting the send address during it is free.

Why harvest here? The alternative is a paid enrichment API (Hunter/Apollo) per domain. On-page harvest covers a large share of DTC stores (they tend to expose support@/help@) at zero marginal cost. It's partial by design - some stores only expose a contact form - and that's fine; those simply don't get emailed.

One honest limitation: the Python crawler uses plain HTTP, no headless browser. A few fully JS-rendered storefronts (e.g. mudwtr.com, drinkhydrant.com) return no readable HTML and get skipped - "no readable pages." The web tool has a headless fallback; the batch tool doesn't yet.


The Cold Email - outreach.py and the Card

This is where the teardown becomes an email. outreach.py reads the prospect list, looks up each store's stored teardown, composes a personalized message, and sends it via Resend.

The Card Is the Message

Rather than a bare link, the email body is a rendered teardown card - email-safe HTML (tables + inline styles), so it survives Outlook and renders even with images blocked. It opens personally:

Subject: ~70% of {brand}'s support tickets look automatable

Hi {brand} team,

This is Julian. I'm researching how AI can help stores handle customer support better - faster replies, more tickets resolved automatically, and less after-hours load. Here's a quick read I did on {brand} using only your public pages:

Then a row of scorecards, deliberately ordered so the human-agent cost (red) sits right next to the AI cost (green):

tickets automatablehuman agents / moAI / mo at $1/resolutioncoverage gap
70%$8,000/mo (red)$1,200/mo (green)128 hrs/wk

…followed by the store's #1 ticket as today vs automated (the model's grounded reply, using only facts from the store's own pages), and a single button: See the full X-ray for {brand} →, linking to /x/<domain>.

Why a card in the email, and why that color pairing? A link asks for a click on faith; a card delivers the value up front. And the red-vs-green cost juxtaposition is the pitch - "$8,000/mo of human agents vs $1,200/mo of AI" is the single line that makes an owner keep reading. (The AI figure is computed as pct% of monthly tickets × $1, not trusted from the model's prose - see "Walls" below.)

Guardrails (Honesty + Deliverability)

Cold email is easy to get wrong, so the defaults are conservative:

.venv/bin/python outreach.py --input prospects.csv -v               # DRY RUN — prints, sends nothing
.venv/bin/python outreach.py --input prospects.csv --send --limit 5 # send, capped
  • Dry-run by default. You have to pass --send. The first thing you do is read the emails.
  • good_fit only (unless --include-all). If the teardown itself says a store is too small or already well-covered, we don't email it. The product is honest; the outreach inherits that.
  • Never double-contacts. Every send is logged to outreach_log; re-running skips already-contacted stores, so you can safely bump --limit across days.
  • Paced. --limit N and --sleep N keep volume sane on a young sending domain.
  • Test override. --to you@example.com --only atlascoffeeclub.com sends one store's card to yourself - bypasses the skips and does not write outreach_log, so you can eyeball the real render before contacting anyone.

Resolving the Send Address

resolve_email() tries the prospect CSV's best_email, then the teardown's harvested contact_emails, then skips - and every candidate is validated (see "Walls" below). A store whose only "email" was a form placeholder is skipped, not spammed.

Why local-only, never CI? outreach.py sends real email to real businesses. Wiring it into a shared CI runner would risk blasting from an unwarmed IP and is easy to fire by accident. It stays a deliberate, local, dry-run-first command.


The On-Site Funnel - "Find Out More"

The email earns a click; the landing page has to convert it. Every teardown page (/x/<domain>) carries a Find out more block (app/components/find-out-more.tsx) - framed as owner conversion, not a generic newsletter:

Want this running on {brand}? Drop your email and we'll send your full plan - how an AI agent would handle {brand}'s top tickets, grounded in your own policies.

On submit it POSTs to /api/enroll, which does three things (app/app/api/enroll/route.ts):

  1. Captures the lead into the Supabase enrollments table (email, name, domain, source).
  2. Fires an auto follow-up email via Resend (app/lib/email.tssendEnrollmentFollowup), subject "Your Customer Support X-ray for {brand}", with their teardown link and next steps.
  3. Optionally pings Slack so the team sees the warm lead immediately.

The UI then confirms: "Check your inbox ✓ - we just sent your teardown and next steps to {email}. Reply to that email and we'll show you exactly how Typewise would run these flows on {brand}."

Why auto-reply instantly? The moment of highest intent is right after they submit. An immediate, personal-feeling follow-up (that invites a reply) beats a lead sitting in a CRM waiting for a human. The whole thing degrades gracefully - if Resend isn't configured, the lead still saves.


Seeing Every Move - Run Logs in the Dashboard

The Python tools run locally, and a terminal scrollback is ephemeral. To make the operation observable, every script streams its moves to Supabase via tools/runlog.py: one row per invocation in runs, one row per move in run_events (level, domain, message, and a JSON detail).

The team dashboard then has three tabs that close the loop:

  • Outreach - one row per store emailed: status (sent / failed / skipped), recipient, when.
  • Enrollments - the warm "Find out more" leads.
  • Activity - a paginated, live view of the run logs. Pick a run, drill into its event stream, filter by level/domain; while a run is still running, it polls and updates in place. So you can watch outreach.py work through the list - sent → support@vegamour.com, skip: no email found - from the browser.

All three are team-only (Postgres Row-Level Security gates reads to allow-listed emails; the read-only shared dashboard never sees lead PII).

Why both an outcome table and an event log? outreach_log answers "who did we contact and what happened" (queryable, durable). run_events answers "what exactly did this run do, step by step" (the play-by-play, for debugging a weird result). You want both.


Walls We Hit (and How We Routed Around Them)

The interesting part of any scraping/outbound system is the messy reality. Four that mattered:

Placeholder Emails That Bounce

A real --send run emailed your@email.com, my@ritual.com, and careers@thisisneeded.com. The first two are form-placeholder text scraped off the page ("enter your email…") - they bounce; the third is the wrong department. Cold-emailing junk addresses doesn't just waste a prospect, it hurts sender reputation.

The fix: a shared validator, tools/emailutil.py, with is_real_contact() and pick_contact(), that rejects placeholder local-parts (your, you, my, name, test, john…), example domains (email.com, example.com…), wrong departments (careers, jobs, press, legal…), and asset filenames scraped as emails (logo@2x.png) - and prefers support@/help@/care@. Crucially it's enforced in three places: at harvest (don't store junk), at qualification, and at send (so it protects addresses already stored, without re-running anything). A store whose only address was a placeholder now correctly skips instead of sending.

Lesson: validate the email at the moment of sending, not just at the moment of scraping. The bad data already in your database is the data that bites you.

A Cost String That Blew Up the Card

For an off-ICP test domain, Claude returned the AI cost as a sentence - "$1,200/mo (estimate: 60% of 2,000 tickets at $1 each)" - and the scorecard rendered that whole string at headline size. The fix: compute the figure from structured fields (pct% of monthly tickets × $1) and only fall back to parsing the largest $ value out of the model's prose. Now it's always a clean $1,200/mo, in both the web card (cleanAiCost) and the email (dollars_per_mo).

Lesson: don't render an LLM's free-text number into a fixed-size UI slot. Derive it from structured inputs and treat the prose as a fallback.

One Word That Mislabelled Every Site

The batch tool's tech-stack detector matched the bare substring "loop" to tag Loop Returns - so any page containing "develop", "loop", etc. got a false "Loop Returns" badge. Tightened to loopreturns (matching the web detector's precise signature).

And the flip side: once the false positive was gone, an off-ICP site showed "none detected" - a prompt placeholder the model echoed back as a fake badge. So we added a platform-tech fallback: if no store (ICP) tech is found, detect the site's real general stack (Webflow, WordPress, Next.js, HubSpot, Google Analytics…) so it shows something real, and a sanitizer drops placeholder echoes. Real prospect cards stay ICP-focused; the fallback only fires when the ICP match is empty.

Lesson: a substring match is not a fingerprint. And when you feed a model a placeholder, expect it to hand the placeholder back - sanitize what comes out.

Python 3.9 on macOS

The tools use X | None type hints (PEP 604, Python 3.10+). macOS ships 3.9, so every script failed at import with TypeError: unsupported operand type(s) for |. Adding from __future__ import annotations to each module makes the hints lazy and the tools run on stock macOS Python.

Lesson: "works on my machine" includes the Python version. The target environment was a laptop, not the dev container.


Compliance & Deliverability

Cold email to real businesses carries real obligations and real failure modes. What's baked in vs. what's operational:

In the code: good-fit-only sending (no spraying), a List-Unsubscribe header and an "reply unsubscribe" line on every message, a reply_to so responses (and opt-outs) reach a human, and dry-run-by-default + --limit so a big send is a deliberate act.

On you (operational): a verified sending domain in Resend (SPF/DKIM - unverified domains simply fail), a sensible warm-up ramp (start at a handful/day on a young domain, not a blast), and honoring replies. The script makes the safe path the default; domain reputation is earned over time.

Why honesty is a deliverability strategy, not just ethics: emailing only stores the teardown rates as a good fit means more relevant sends, fewer complaints, and a healthier sender score. The product's honesty and the campaign's deliverability point the same direction.


End-to-End Recap

From a niche to sent emails, run from tools/:

# 1) qualify + rank (no Claude) → prospects.csv + targets.csv
.venv/bin/python find_prospects.py --input seeds/supplements.csv --min-score 5 --write-targets 20

# 2) full teardown of the keepers → Supabase `teardowns` (+ harvested emails)
.venv/bin/python batch_xray.py --input targets.csv

# 3) review the cold emails, then send a small, paced batch
.venv/bin/python outreach.py --input prospects.csv -v
.venv/bin/python outreach.py --input prospects.csv --send --limit 5

Owner clicks the card → lands on their own /x/<domain> teardown → hits Find out more → enrolls → gets an instant follow-up → shows up in the Enrollments tab, while the send shows in Outreach and the whole run is replayable in Activity.

The funnel math: source thousands → qualify for free → teardown the top N (cost = your call) → email only the good fits, paced. You scale the top of the funnel infinitely and meter the only expensive step.


What's Next

  • Headless-render fallback in batch_xray so fully-JS storefronts stop coming back empty/skipped.
  • Bounce & complaint webhooks (Resend → outreach_log) so the dashboard reflects real deliverability, and hard-bounced domains auto-suppress.
  • Optional enrichment API as a third email tier for stores that expose only a contact form.
  • Parallelized crawl + analyze for thousand-store batches (it's sequential today, ~30 - 45s/store).

The shape won't change: source cheap, qualify cheaper, spend the model only where it pays, and let the teardown - the thing that's actually about them - be the reason they open the email.

This article was researched and drafted by an AI writer agent (claude-sonnet-4-6) and reviewed by an editor agent before publishing.

Ask about Julian Walder

Grounded in his real work

Hi! I'm Julian Walder's assistant. Ask me anything about his work, projects, or background in AI.