Customer Support X-ray: Building a Tool That Is Its Own Distribution
A free tool reads a store's public support pages and mirrors back how an AI agent would handle their tickets - grounded, honest, and shareable. The build, the stack trade-offs, and the strategy that made distribution the product.
Customer Support X-ray is a free tool: paste a store URL, it reads the brand's public support pages, and shows how an AI agent would handle their top tickets - with a grounded auto-reply and honest cost and coverage estimates. Every result has a shareable URL and a social-preview image.
Live: cs-xray.walder.dev · Stack: Next.js 16 (App Router) · TypeScript · Tailwind v4 · shadcn/ui · Anthropic Claude · Vercel + Vercel KV (Upstash Redis).
What it does, in 30 seconds
- You paste a store URL (e.g.
drinktrade.com). - The server fetches the homepage, fingerprints the tech stack, finds the support and policy pages, and reads them.
- A single Claude call turns that text into a structured teardown: the top support tickets, a grounded auto-reply (using only facts from the store's own pages), and labelled cost and coverage estimates.
- The result renders at a shareable URL
/x/{domain}with a rich social-preview image, and the store joins a public "recently x-rayed" gallery.
The product's spine is a single hard rule: facts come from the store's own pages; everything modelled is explicitly labelled an estimate. That honesty is what makes a result worth sharing.


Strategy: distribution is the product
A note on where strategy lives. Strategy isn't a stage between Solution and Results - it's the logic that runs through the whole build. The choices below (which segment, which angle, which channel, and why) are what make this particular tool and this particular distribution feel inevitable rather than arbitrary. Read linearly, the arc is Problem -> Insight -> Solution -> Distribution -> the bet.
The problem: the doorway, not the product. Picture a customer-support platform that already works - enterprises run on it, and a self-service tier lets a founder sign up, drop in a card, and have an AI set up their support automation in about 15 minutes. What's missing is discovery. The self-service buyer - a DTC founder or ops lead - starts at a Google search, a Reddit thread, a "best Zendesk alternatives" roundup. Those surfaces are already owned by Intercom, Zendesk, and Ada. The platform is the one name the buyer never sees at the exact moment they're looking for it. The usual answers are taken or lose: outbound is covered, you can't out-rank a decade of SEO authority, and paid ads are distrusted by the technical buyer. The real gap is the research moment itself.
The insight (the reframe). This is the non-obvious step that bridges problem and solution - and without it the tool would look arbitrary. Two realizations:
- Distribution is the product. Building a clever tool and shipping it is not the finish line; an artifact no one encounters changes nothing. The scarce skill is distribution. So the move is to build the minimum thing that is itself a distribution channel - an artifact whose every use puts the platform in front of a buyer at the moment of pain. Build to be discovered, not just to be good.
- The research moment is polluted, so honesty is the wedge. Search that moment
and nearly every result ranks itself number one, including the pages literally
titled "honest comparison." A tool that sometimes tells a store "you don't need
this yet" stands out precisely because no one else will say it. Candour is the
trust-and-share trigger - and it later becomes a literal feature (
fit_verdict).
With that insight, everything downstream stops being arbitrary and starts feeling inevitable.
The solution: the Mirror. Narrow hard. One segment: DTC e-commerce and subscription brands - reachable in public communities, drowning in repetitive tickets, and exactly the credit-card buyer the self-service product is built for. One angle: the after-hours "where is my order?" gap. Most of these brands staff support roughly nine-to-five, Monday to Friday - about 40 of the 168 hours in a week. For the other 128, a customer hears nothing until Monday, even though the brand's own shipping policy already holds the answer. The answer exists; nobody's awake to send it - and it's verifiable from the store's own public pages, so it can be proven without inventing anything. Paste a store URL and in about 30 seconds the Mirror reads the public support surface and shows the store its own setup: top tickets, how they're handled today, how an AI agent would handle them (a reply grounded in the store's own policy), and a scorecard. People scroll past demos; they don't scroll past a thing that's about them.
Distribution: built to spread, no ad budget. Three loops:
- Outbound that isn't outbound - run the tool on a named founder's store and send them their own result; a message they open because it's useful and about them, not a pitch.
- Community seeding - drop the teardown into the exact "I need a chatbot that reads my order data" / "Zendesk alternative?" threads as the most helpful answer in the room.
- The vanity loop - founders share their own teardown ("someone X-rayed my support, kind of wild"), putting the platform in front of their peers for free.
Two pieces of plumbing amplify all three, and both are first-class features in the build: every teardown has its own URL that previews as a rich card when pasted anywhere (the dynamic OG image), and every analysed store is captured to a self-qualifying leads list. That list isn't infrastructure; it's the product working on the business - a live roster of stores that just raised their hand about a support problem.
The bet. "Viral" is the design intent, not a promise - the loops are engineered, but whether they catch is a hypothesis. Which is exactly why the tool is built to ship in days and be measured in runs, shares, replies, and captured leads - and why the build instruments precisely those.
How it was built: spec-first, phased, ship-after-each
The project started from a written build brief that pre-decided the ambiguous choices (stack, data flow, the exact analysis prompt, the JSON schema). Work then ran in shippable phases, deploying after every one so there was always a live URL:
| Phase | What shipped |
|---|---|
| 0 - Deploy first | create-next-app scaffold, empty page deployed to Vercel to prove the pipe |
| 1 - Core | Crawl -> stack detection -> Claude analysis -> result card at /x/[domain], plus KV cache + lead capture |
| 2 - Distribution | Per-domain OG/Twitter image, copy-link, "run another", per-domain metadata |
| 3 - Polish | Deterministic text cleanup, IP rate-limiting, hardened error/loading states |
| 4 - Resilience | ScrapingBee headless fallback for JS-only stores |
| + Iteration | Public gallery, share-by-email capture, animated progress, theming, moderation endpoint |
Every phase is independently demoable and reversible. A regression is contained to one small, reviewable diff; the product is never half-broken between big-bang merges.
Architecture and data flow
One server-rendered route does the work. There is no relational database and no
headless browser in the hot path - plain fetch plus a key-value store is enough
because most storefronts are server-rendered.
URL input (client form)
-> /x/[domain] (server component, force-dynamic)
-> KV cache hit? -- yes --> render instantly (~1s)
| no
v
rate-limit by IP (KV or in-memory; fails open)
-> fetch homepage (8s timeout, desktop UA, follow redirects)
-> detect tech stack from HTML + headers (fingerprints)
-> collect policy/support links from footer/nav (+ Shopify fallbacks)
-> fetch up to 6 pages in parallel, strip HTML -> text
-> (JS-only store? -> re-render via ScrapingBee)
-> single Claude call -> structured JSON -> parse (+1 repair retry)
-> deterministic text cleanup
-> write 24h cache + append lead + bump counters + add to recent set
-> render the result card
-> /x/[domain]/opengraph-image reads the SAME cache -> rich social card
-> /recent joins the recency set with the cache -> public gallery (ISR 60s)
The URL is the cache key and the share unit: https://<host>/x/drinktrade.com. The
domain is the slug, the cache key, the OG-image key, and the gallery member - one
identifier all the way down.
Stack choices: benefits and trade-offs
Next.js 16 (App Router) on Vercel
One framework gives server components (secrets stay server-side), file-based routing where the dynamic segment is the share URL, built-in metadata and OG-image generation, and first-class Vercel deploys. The result page is a server component that does the crawl and the LLM call directly - no separate API layer.
// app/x/[domain]/page.tsx - Next 16: params is async, typed via the global PageProps helper
export const dynamic = "force-dynamic"; // live crawl + LLM per request on cache miss
export const maxDuration = 60; // give the LLM call room
export default async function XrayPage({ params }: PageProps<"/x/[domain]">) {
const { domain } = await params; // <- await, not a plain object
const normalized = normalizeDomain(domain);
// ...
}
The honest trade-off: Next 16 has real breaking changes vs. older mental models -
params is a Promise, revalidateTag takes a second argument, route-caching
semantics shifted. I read the version's own docs before writing routes rather than
relying on memory.
TypeScript end-to-end
A single Analysis interface is the contract shared by the LLM's JSON schema, the
cache, the result card, the OG image, and the gallery projection. Change the shape
once, and the compiler finds every consumer.
Anthropic Claude (Sonnet), server-side only
The task is grounded extraction plus light modelling, not open-ended generation, so Sonnet is the sweet spot of grounded-reply quality vs. latency and cost; thinking is disabled for fast, deterministic JSON. A fresh teardown is about 30-40s (live crawl + model); the 24h cache turns that into a one-time cost per domain, so every reshare or preview is about 1s.
Vercel KV (Upstash Redis), not SQL
v1 needs a cache, a leads list, counters, a recency index, and a blocklist - all
natural Redis primitives (GET/SET ex, LPUSH, INCR, sorted sets, sets). No schema,
no migrations. The cache pays for itself immediately, and the leads list doubles as the
product's measurement and lead asset. The whole layer degrades to a no-op when KV
isn't configured, so the app still runs locally with zero setup.
Plain fetch, ScrapingBee only as a fallback
Most storefronts are server-rendered, so fetch extracts the policy text directly - no
Puppeteer cold-starts or bundle bloat. A hosted browser is engaged only when a
homepage comes back too thin to be JS-rendered, gated behind an env var so the crawl
behaves identically without it.
The crown jewel: grounded analysis
The whole product lives or dies on one rule: never invent facts. That's enforced in the system prompt and defended in code.
Hard rules:
- Use ONLY facts present in the provided text. Never invent policies, hours, prices,
carriers, or numbers. If a fact is not present, use "not stated".
- Every "grounded_reply" may ONLY reference details found in the provided pages, and
must name which page it came from in "grounding_source".
- Separate FACTS (from pages) from ESTIMATES (your modeling). Mark every estimate.
- Be honest. If the store looks low-volume or already well-covered, say so.
- Output ONLY valid JSON matching the schema. No prose, no markdown, no code fences.
The model returns a fixed schema: brand, detected stack, support hours and channels,
policy summaries, top tickets (each with a grounded_reply and grounding_source), an
automatable percentage, a cost model, and crucially a fit_verdict
(good_fit | too_small | already_covered) so the UI can show candour instead of a hard
sell when the store doesn't need the product.
Defensive parsing plus one repair retry
LLM JSON can arrive wrapped in stray prose or code fences. The parser strips fences,
extracts the outermost {...}, and parses it. On failure it does exactly one repair
round-trip, handing the model its own output back:
try {
return normalize(parseAnalysis(firstText), crawl);
} catch {
const repair = await client.messages.create({
model: MODEL, system: SYSTEM_PROMPT,
messages: [
{ role: "user", content: userMessage },
{ role: "assistant", content: firstText },
{ role: "user", content: "That was not valid JSON. Return ONLY the valid JSON object." },
],
});
return normalize(parseAnalysis(textFromMessage(repair)), crawl);
}
Deterministic text cleanup (no model in the loop)
LLMs love em-dashes. Rather than ask the model to avoid them (unreliable), a pure string pass normalizes every long-dash variant to an ASCII hyphen across all model output - deterministic, testable, free:
const LONG_DASHES = /[‐‑‒–—―−⸺⸻﹘﹣-]/g;
export const replaceLongDashes = (s: string) => s.replace(LONG_DASHES, "-");
// applied to the whole analysis object via JSON round-trip in normalize()
The crawl and stack detection
Stack detection is substring/regex fingerprints over HTML and headers - cheap, dependency-free, and doubling as lead qualification (Shopify + Recharge = subscription brand = WISMO-heavy = strong fit).
const FINGERPRINTS = [
{ name: "Shopify", category: "ecommerce", html: /cdn\.shopify\.com|\/cdn\/shop\/|powered by shopify/, header: /x-shopid/ },
{ name: "Gorgias", category: "helpdesk", html: /gorgias\.help|config\.gorgias\.io|gorgias/ },
{ name: "Recharge", category: "subscriptions", html: /rechargecdn\.com|recharge/ },
// ...Zendesk, Intercom, Klaviyo, Loop Returns
];
Candidate links come from any footer/nav anchor whose href or text matches
shipping|return|refund|faq|help|contact|policy|terms. If Shopify is detected, known
paths are added as fallbacks. Links are deduped, constrained to the same domain, capped
at 6, and fetched in parallel - each with an 8s timeout and individual failure tolerance.
const fetched = await Promise.all(candidates.map(async (url) => {
try {
const res = await fetchWithTimeout(url);
let text = res.ok ? htmlToText(await res.text()) : "";
if (text.length < 80 && renderMode) { // JS-only fallback
const rendered = await fetchRendered(url); // ScrapingBee
if (rendered) text = htmlToText(rendered);
}
return text.length < 80 ? null : { url, text };
} catch { return null; }
}));
HTML-to-text strips <script>/<style>/<nav>/<header>/<svg>, decodes common entities,
collapses whitespace, and truncates each page to about 1,500 tokens so the prompt stays
bounded.
Caching, leads, and a public gallery - all in Redis
lib/cache.ts is a thin, fail-soft wrapper. Every function no-ops without KV.
| Key | Type | Purpose |
|---|---|---|
xray:result:{domain} | string (JSON), 24h TTL | the cached teardown - the share/preview source |
xray:leads | list | every analyzed domain + timestamp (lead asset) |
xray:recent | sorted set (score = time) | the public gallery index (deduped by domain) |
xray:recent:blocklist | set | domains hidden from the gallery (moderation) |
xray:counter:runs / :share:{domain} | counters | measurement |
The gallery reads the last-24h window of a time-scored sorted set, drops blocklisted domains, and joins each to its cached result - then ISR-caches the page for 60s so Redis isn't hit on every visit:
const [asc, blocked] = await Promise.all([
redis.zrange(RECENT_KEY, since, now, { byScore: true }),
redis.smembers(RECENT_BLOCKLIST_KEY),
]);
const domains = asc.reverse().filter((d) => !blocked.includes(d)).slice(0, limit);
const results = await Promise.all(domains.map(getCachedResult)); // join to cache
Moderation needs no dashboard: because KV is only mutable by code running on the
deployment, removing an entry is a token-protected endpoint that blocklists the
domain, clears its cache, and revalidates the gallery (note Next 16's two-argument
revalidateTag).
Distribution is a feature, not an afterthought
Pasting a result link into Slack, X, or LinkedIn should show the teardown as a rich
card. app/x/[domain]/opengraph-image.tsx uses Next's built-in next/og and reads
only from the cache - it never triggers a 30-40s crawl (which would time out for a
social scraper). The domain is the hero; the brand is the subtitle; the three headline
stats are the coverage gap (red), automatable percentage, and AI cost (green).


A detail that mattered:
ImageResponsesupports flexbox only (no grid), so the card is built entirely with flex.
generateMetadata reads the same cache to produce a data-rich preview - "Trade Coffee:
~65% of support tickets look automatable, 80 hrs/week coverage gap, est. $2,600/mo..." -
so the link itself sells the result before it's opened. A copy-link button bumps a share
counter; a share-by-email control captures the address to KV and hands off via mailto:,
with no email-provider dependency.
Polish that earns trust
- IP rate-limiting (8 fresh analyses / 10 min): fixed-window via Upstash when present, in-memory otherwise, fails open, exempts localhost, and only counts cache misses so reshares stay free.
- Animated scan progress: the first run is genuinely 30-40s, so
loading.tsxstreams a stepper through the real pipeline stages instead of a dead spinner. - Honest fit states: when the model returns
too_small/already_covered, the card leads with that verdict instead of a hard sell - the candour is the share mechanic. - Facts vs. estimates, visibly separated: scorecards carry an italic "Estimated" footer behind a divider; nothing modelled is ever shown as fact.
Notable engineering decisions (the trades log)
| Decision | Chose | Over | Because |
|---|---|---|---|
| Data store | Vercel KV / Redis | Postgres/Supabase | Only need cache + lists + counters + sets; zero schema; instant |
| Crawling | fetch + optional ScrapingBee | Puppeteer always | Storefronts are server-rendered; avoid cold-starts/bundle bloat |
| LLM JSON | prompt + parse + 1 repair | strict tool/schema mode | Matches the brief; fully transparent; no extra dependency |
| Dash hygiene | deterministic regex | ask the model | Reliable, testable, free - no tokens, no variance |
| Theming | shadcn presets (one knob) | hand-rolled CSS | Reskin = config change; honest about build-time constraint |
| OG image | next/og, cache-only | render on demand | Social scrapers can't wait 40s; cache read is instant |
| Email share | mailto: + KV capture | server-send provider | No new dependency, key, or sender-domain verification |
| Degradation | KV/ScrapingBee optional, fail-open | hard dependencies | App runs locally and never blocks a legitimate request |
Known limitations and what's next
- First-load latency (~30-40s on a never-seen domain) is inherent to a live crawl + LLM; mitigated by the cache and the progress UX. Next levers: trimmed timeouts, fewer pages, or a Haiku "fast mode" toggle.
- JS-only stores rely on the optional ScrapingBee fallback.
- Email leads have no consent/unsubscribe flow yet (v1 lead capture).
At a glance
- Frontend/runtime: Next.js 16 (App Router, RSC, Turbopack), React 19, TypeScript, Tailwind v4, shadcn/ui (preset-themed), dark/light.
- Backend (in-app): server components plus a few route handlers; Anthropic SDK (Sonnet, server-side); Upstash Redis for cache/leads/counters/gallery/rate-limit; optional ScrapingBee.
- Distribution: dynamic OG and Twitter images (
next/og), data-rich per-domain metadata, copy-link and email-capture sharing, public recency gallery. - Ops: Vercel hosting + ISR; env-gated optional services; token-protected moderation; deterministic, fail-soft, dependency-light.