All posts
EngineeringJune 5, 202614 min read

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

  1. You paste a store URL (e.g. drinktrade.com).
  2. The server fetches the homepage, fingerprints the tech stack, finds the support and policy pages, and reads them.
  3. 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.
  4. 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.

Result card - light theme

Result card - dark theme

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:

  1. 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.
  2. 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.
  3. 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:

PhaseWhat shipped
0 - Deploy firstcreate-next-app scaffold, empty page deployed to Vercel to prove the pipe
1 - CoreCrawl -> stack detection -> Claude analysis -> result card at /x/[domain], plus KV cache + lead capture
2 - DistributionPer-domain OG/Twitter image, copy-link, "run another", per-domain metadata
3 - PolishDeterministic text cleanup, IP rate-limiting, hardened error/loading states
4 - ResilienceScrapingBee headless fallback for JS-only stores
+ IterationPublic 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.

KeyTypePurpose
xray:result:{domain}string (JSON), 24h TTLthe cached teardown - the share/preview source
xray:leadslistevery analyzed domain + timestamp (lead asset)
xray:recentsorted set (score = time)the public gallery index (deduped by domain)
xray:recent:blocklistsetdomains hidden from the gallery (moderation)
xray:counter:runs / :share:{domain}countersmeasurement

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).

OG card - Trade Coffee

OG card - Death Wish Coffee

A detail that mattered: ImageResponse supports 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.tsx streams 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)

DecisionChoseOverBecause
Data storeVercel KV / RedisPostgres/SupabaseOnly need cache + lists + counters + sets; zero schema; instant
Crawlingfetch + optional ScrapingBeePuppeteer alwaysStorefronts are server-rendered; avoid cold-starts/bundle bloat
LLM JSONprompt + parse + 1 repairstrict tool/schema modeMatches the brief; fully transparent; no extra dependency
Dash hygienedeterministic regexask the modelReliable, testable, free - no tokens, no variance
Themingshadcn presets (one knob)hand-rolled CSSReskin = config change; honest about build-time constraint
OG imagenext/og, cache-onlyrender on demandSocial scrapers can't wait 40s; cache read is instant
Email sharemailto: + KV captureserver-send providerNo new dependency, key, or sender-domain verification
DegradationKV/ScrapingBee optional, fail-openhard dependenciesApp 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.

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.