<!--
Pre-Sale Chatbot Playbook

Part of the free Claude Code template library from BILT AI — https://biltai.io/claude-templates

These are the actual playbooks used to build and ship production SaaS apps with
Claude Code. They're written to be handed to an agent as-is: point Claude at one
and it has the full pattern, the schema, the gotchas, and the "don't do this"
list. Paths, project refs, and org names have been replaced with placeholders
(<your-app>, <supabase-project-ref>, <your-github-org>) — search for "<" and
fill in your own before running.

Free to use, modify, and ship. No attribution required.
-->

# Pre-Sale Chatbot Playbook

**Portable spec for dropping a grounded, self-installing pre-sale AI assistant onto any app's marketing site — a floating chat widget + streaming corpus-grounded endpoint that answers "what is this / how much / how does it work / where do I find X" and routes ready visitors to signup. One kickoff action builds the knowledge base from the target app itself.**

Built from the BKE / Kompozy implementation (the "Rozy AI" assistant on kompozy.io). Reference repo: `reference app`.

> **Relationship to DEMO-SANDBOX-PLAYBOOK:** that playbook lists this chatbot as "Pattern B" at a high level. THIS playbook is the deep, one-action install — the widget, the auto-knowledge-base build, and the provider/key wiring. Use DEMO-SANDBOX for the shared anonymous infra (rate-limit buckets, admin-key cost containment) and this one for the chatbot itself.

---

## 0. When to use this — and the tier rationale

**Tier: A (high leverage for any marketing-facing app).** Every portfolio app ships a marketing site; a pre-sale assistant grounded in that app's real pricing/features is a direct trial-conversion lever and reuses the same anonymous-endpoint infra you already build. It is not an S-tier foundation (the app works without it), so it sits in A-tier alongside CUSTOMER-ANALYTICS and COLD-EMAIL.

**Use it when:** the app has a public marketing site and visitors arrive with pre-purchase questions the site already answers somewhere (pricing, plans, integrations, comparisons, "can I see it"). The bot deflects those into instant answers and points high-intent visitors at `/signup` / `/pricing`.

**Skip it when:** there is no public marketing surface (internal tools), or the product is invite-only / sales-led with no self-serve signup to route to.

**Core thesis:** a grounded bot that quotes the *real* corpus and never invents pricing beats a generic LLM widget. The whole value is that it cannot hallucinate features — it answers ONLY from a corpus you generate from the app's own site + code. That is also why it must be re-synced when the product changes (see §10).

---

## 1. What you get (the install surface)

A working chatbot is **4 owned files + 4 wiring touches**:

Owned (copied + adapted per app):
1. `src/lib/help/public-corpus.ts` — the knowledge base (auto-generated, §3). **This is the part that "understands the app."**
2. `src/app/api/public-chat/route.ts` — streaming, corpus-grounded, provider-fallback endpoint.
3. `src/components/landing/LandingChat.tsx` — the floating widget (thread persistence, SSE render, link-rewriting).
4. `src/components/landing/LandingChatLazy.tsx` + `LandingChatGate` — dynamic-import wrapper + route gate that self-hides the widget on app routes.

Wiring (edit existing files):
5. `src/proxy.ts` — add `/api/public-chat` to `PUBLIC_API_PREFIXES` (edge default-deny).
6. `src/app/layout.tsx` — mount `<LandingChat />` once (lazy).
7. `src/lib/rate-limit.ts` — reuse the existing Upstash enforcer; add the two chat buckets.
8. Provider keys — `getSandboxTextKeys()` from the admin console (preferred) or an env fallback (§4).

Dependency types: `HelpBlock` / `HelpArticle` live in `src/lib/help/content.ts` — copy that type or the file.

---

## 2. Architecture

```
src/
├── proxy.ts
│   └── PUBLIC_API_PREFIXES = [ '/api/public-chat', ... ]   # edge allowlist; default-deny
│
├── lib/
│   ├── help/
│   │   ├── public-corpus.ts        # PUBLIC_ARTICLES[]  ← the knowledge base (autobuilt)
│   │   └── content.ts              # HelpBlock / HelpArticle types (+ in-app support corpus)
│   ├── admin-keys-server.ts        # getSandboxTextKeys() → [primary, fallback] enterprise slots
│   └── rate-limit.ts               # enforceRateLimit(bucket, key) — Upstash sliding window
│
├── app/
│   ├── api/public-chat/route.ts    # streaming endpoint, OpenAI-primary, corpus in system prompt
│   └── layout.tsx                  # mounts <LandingChat/> (lazy) once, app-wide
│
└── components/landing/
    ├── LandingChat.tsx             # widget UI + SSE consumer + link rewriter
    ├── LandingChatLazy.tsx         # dynamic(() => import('./LandingChat'), { ssr:false })
    └── LandingChatGate.tsx         # returns null on /dashboard,/admin,/onboarding,/signin,/signup
```

Flow: `visitor → LandingChat (POST /api/public-chat, SSE) → route injects renderCorpus(PUBLIC_ARTICLES) into a cached system prompt → OpenAI (primary) / Anthropic (fallback) → SSE stream → widget renders + rewrites route mentions into clickable buttons → /signup`.

---

## 3. The knowledge-base autobuild ← the heart of "one action"

This is what makes the chatbot "ready to go" against a new app. The agent generates `public-corpus.ts` from TWO ground-truth sources and reconciles them. **Never hand-write the corpus from memory and never trust a single source.**

### 3a. Pull both sources

**Source A — the live marketing site (what visitors actually see).** WebFetch the key pages and extract verbatim copy:
- `/` (homepage — hero one-liner, section headings, any pricing/credit table, platform/feature lists, testimonials, demo anchor)
- `/pricing` (every plan name, price, monthly+annual, included limits, "most popular" badge, top-ups, guarantee)
- `/signup` (is it free-trial or paid-only? what's the first step?)
- Any product subpages the footer/nav expose (`/outputs`, `/features`, `/integrations`, comparison pages, etc.)
- The footer + nav link list → this becomes the sitemap article.

**Source B — the codebase (authoritative for exact numbers).** Read, don't scrape:
- Plan/tier config (e.g. `src/lib/plan.ts`, `src/app/pricing/page.tsx`, Stripe price config) → exact prices, credits, limits, trial-vs-paid.
- Per-unit cost table if credit-metered (e.g. `src/lib/credits/pricing.ts`, `src/lib/formats.ts`) → exact per-output costs.
- Feature/format catalog → real list of what the product produces, minus anything gated "coming soon".
- Publishing/integration constants → the real wired list.
- Refund/guarantee/cancellation copy (`src/app/refund-policy`, billing logic).
- Glob `src/app/*/page.tsx` → the REAL set of public marketing routes, so the sitemap article never points at a 404.

### 3b. Reconcile (the rules)

- **Exact numbers → code wins.** Prices, credit costs, limits come from config, not scraped marketing copy (copy lags reality — that is the whole bug this prevents).
- **Public-facing tables → mirror the site verbatim.** If the homepage shows a credit cost table, the corpus table must match it line-for-line (the bot's job is to agree with what the visitor sees).
- **On conflict, flag it.** If the live site says X and code says Y, surface it to the operator — that drift is a marketing-site bug to fix at the source, not something to bury in the corpus.
- **Sitemap = only routes that exist.** Verified against the glob, not aspirational.

### 3c. Emit `public-corpus.ts`

The corpus is a typed array of articles. Each article is keyword-tagged so the model can find it, and a body of `HelpBlock`s.

```ts
export interface PublicArticle {
  id: string;
  title: string;
  keywords: string[];               // route the model to the right article
  body: HelpBlock[];                // p | h | ul | steps | note | link
}
export const PUBLIC_ARTICLES: PublicArticle[] = [ /* ... */ ];
```

**Canonical article taxonomy (generalize per app — ~14 articles):**
`what-is-<app>`, `core-outputs-or-features`, `integrations-or-publishing`, `modes-or-options` (if any), `automation-or-key-mechanic`, `plans-and-pricing` (authoritative), `special-tier` (founding/lifetime/BYO, if any), `per-unit-cost-table` (if metered), `inputs-or-supported-sources`, `live-demo` (point at the demo anchor), `social-proof` (testimonials/reviews/case-studies pages), `differentiators` (vs named competitors), `site-map` (every real subpage + landing anchors), `cancellation-refunds`, `getting-started`.

Drop the ones that do not apply; keep the IDs descriptive. Always end the file with a header comment dating the last sync and naming the sources, so the next agent knows it can drift.

> Reference the freshly-synced BKE corpus as the gold-standard shape + tone: `src/lib/help/public-corpus.ts`.

---

## 4. Provider + key config (OpenAI-primary, admin-console keys)

The endpoint resolves keys from the **admin console**, never `.env`, when the app has the enterprise-key bridge (see ADMIN-CONSOLE-PLAYBOOK):

```ts
const slots = await getSandboxTextKeys();                       // [primary, fallback] valid slots
const openaiSlot    = slots.find((s) => s.provider === 'openai')    || null;
const anthropicSlot = slots.find((s) => s.provider === 'anthropic') || null;
// OpenAI is PRIMARY; Anthropic is the fallback. Iterate by preference, not by slot order.
if (openaiSlot)    { const out = await callOpenAI(openaiSlot, messages);    if (out) return out; }
if (anthropicSlot) { const out = await callAnthropic(anthropicSlot, messages); if (out) return out; }
```

Notes:
- `getSandboxTextKeys()` reads `enterprise_api_keys` (slots `ent-text-primary`, `ent-text-fallback`) via the service-role client, and bypasses `beta_mode` — the bot always runs on the operator's keys, not a visitor's. For OpenAI to actually be primary, **a valid OpenAI key must occupy one of the two text slots in Admin → Settings → API Keys** (the route only finds the OpenAI slot if it exists there).
- The model used is the slot's `selectedModel` (defaults to `gpt-4o-mini` for OpenAI, Haiku for Anthropic). Pick a cheap, fast model — this is a marketing bot.
- **No admin console in this app?** Fall back to `process.env.OPENAI_API_KEY` (and `ANTHROPIC_API_KEY`) with the same preference order. Keep the server-side-only rule — the key never reaches the browser.
- OpenAI: hit `/v1/responses` first, fall back to `/v1/chat/completions` on a 404 so a chat-style model id still works. Translate both SSE shapes into the Anthropic `content_block_delta` shape the widget consumes (so the widget has ONE parser).
- `max_tokens: 384`. Marketing answers are short; never hand a demo bot a 4096 budget.
- Cache the static system prompt: Anthropic `cache_control: { type: 'ephemeral' }`; for OpenAI the corpus rides in `instructions` / the system message.

---

## 5. System prompt contract

The system prompt = a fixed behavior block + `renderCorpus(PUBLIC_ARTICLES)`. Non-negotiable rules to carry over (they are what make it safe + converting):

- **Ground every answer in the corpus.** Never invent features, pricing, integrations, or plan details not in it.
- **Terse.** 2–4 short paragraphs or a tight list. No "I'd be happy to help!", no exclamation marks.
- **Quote pricing exactly** as written; never extrapolate.
- **No lead capture, ever.** Never ask for email/name/phone — signup is the conversion event. Route ready visitors to `/signup`, pricing questions to `/pricing`.
- **Anti "I don't have it":** the corpus carries a full sitemap; if something lives on the site, point at the URL. Never claim testimonials/demo/docs "don't exist" when the sitemap article lists them.
- **Stay in scope:** off-topic (coding help, weather) → briefly redirect to being the app's assistant.
- **Name:** the assistant has a per-app name (BKE's is "Rozy AI") — set it in the prompt.

> Gold-standard prompt: `src/app/api/public-chat/route.ts` (`STATIC_SYSTEM_PROMPT`).

---

## 6. The widget

Copy `LandingChat.tsx` + the lazy wrapper + the gate, then mount once in the root layout:

```tsx
// src/app/layout.tsx
import LandingChat from "@/components/landing/LandingChatLazy";   // dynamic, ssr:false
// ...
<LandingChat />
```

Carry over these widget behaviors (they matter for conversion + cost):
- **Lazy mount** (`dynamic(..., { ssr:false })`) so the heavy widget is not in the initial bundle/SSR.
- **Route gate** — return `null` on `/dashboard`, `/admin`, `/onboarding`, `/signin`, `/signup`. The bot is pre-sale only.
- **Thread persistence** in `localStorage` (cap ~30 messages — keep in lockstep with the route's `BodySchema.max(30)` or long threads 400).
- **Link rewriting** — parse assistant text and turn route mentions (`/pricing`, `/#demo`) and readiness phrases ("sign up") into clickable buttons that navigate. This is the conversion mechanic.
- **SSE consumer** expects the Anthropic `content_block_delta` → `text_delta` shape (§4 normalizes OpenAI into it).

> Reference: `src/components/landing/LandingChat.tsx`, `LandingChatLazy.tsx`.

---

## 7. Anonymous rate limiting (required)

Two buckets — see DEMO-SANDBOX-PLAYBOOK §4 for the full rationale:
1. Per-IP: `enforceRateLimit('public-chat', ip)` — caps one IP's burst (~30/hr for chat).
2. Global circuit breaker: `enforceRateLimit('public-chat-global', 'all')` — caps total anon traffic so a botnet rotating IPs can't drain admin keys. **Non-negotiable** — every call spends the operator's keys.

Upstash in prod (survives Lambda warm boots); in-memory only in dev. IP from `x-forwarded-for` → `x-real-ip` → `'unknown'`.

---

## 8. The one-action install sequence

When the kickoff prompt fires, the agent runs this in order:
1. **Autobuild the corpus** (§3): crawl the live site + read the code, reconcile, write `public-corpus.ts`. Flag any site↔code drift in the final report.
2. **Install the route**: copy `public-chat/route.ts`, set the assistant name + product one-liner in the system prompt, confirm OpenAI-primary ordering (§4).
3. **Install the widget**: copy `LandingChat.tsx` + lazy + gate, set brand name/colors, mount in `layout.tsx`.
4. **Wire the edge**: add `/api/public-chat` to `PUBLIC_API_PREFIXES`; confirm `rate-limit.ts` + Upstash env exist (add buckets if not).
5. **Verify provider keys**: query `enterprise_api_keys` text slots (or check env) — confirm a valid OpenAI key sits in a text slot; if not, tell the operator to set it.
6. **Typecheck**: `node node_modules/typescript/bin/tsc --noEmit` (NOT `npx tsc` — it's a squatter stub) and fix.
7. **Report**: files written, the corpus article list, any drift found, and the smoke-test result.

---

## 9. Verification / smoke test

- `tsc --noEmit` clean.
- `POST /api/public-chat` with `{ messages:[{role:'user',content:'how much does it cost?'}] }` streams an SSE answer quoting the real plans.
- Ask "do you have a demo / testimonials?" → bot points at the real anchors/subpages, never "I don't have that."
- Ask "I want to sign up" → bot points at `/signup` and the widget renders it as a clickable button.
- Load a `/dashboard` route → widget is absent (gate works).
- Hammer the endpoint past the per-IP cap → 429.

---

## 10. Keeping the corpus fresh (it does NOT self-update)

By design the corpus is **static code** — the bot quotes exactly what's in `public-corpus.ts`, nothing live. When the product changes (new pricing, new features, new signup flow), the corpus must be re-synced or the bot lies. Two options:
- **Manual re-sync (default):** re-run §3 ("crawl + reconcile + rewrite") whenever the product changes materially, commit, deploy (it's a Vercel route — no worker redeploy).
- **Scheduled re-sync (optional):** a `/schedule` or Trigger.dev cron that re-runs the autobuild on a cadence and opens a PR with the diff for review. Do NOT auto-commit corpus changes unreviewed — a bad scrape would ship wrong pricing to every visitor.

Always stamp the corpus file header with the last sync date + sources (the BKE corpus does this).

---

## 11. Common pitfalls

- **❌ Hand-writing the corpus from memory.** It will be wrong within a month. Always autobuild from site + code (§3).
- **❌ Trusting only the scraped site.** Marketing copy lags shipped reality — exact prices/credits come from code, or the bot quotes stale numbers (this is the exact bug the BKE corpus had: HeyGen went BYO, persona-format credit costs dropped ~530→6-16, and the corpus still quoted the old numbers for weeks).
- **❌ Forgetting the `proxy.ts` allowlist.** Under edge default-deny, `/api/public-chat` 401s before the handler runs. Add it to `PUBLIC_API_PREFIXES`.
- **❌ Hardcoding provider order against the admin config.** BKE originally tried Anthropic first regardless of what was set as primary — a latent bug. Prefer by provider intent (OpenAI primary), and remember the route only finds an OpenAI slot if one is configured in a text slot.
- **❌ Mounting the widget on app routes.** It's pre-sale only — gate it off `/dashboard`,`/admin`,`/onboarding`,`/signin`,`/signup`, or it nags logged-in users.
- **❌ Capturing leads.** No email gate. Signup is the conversion event; an email ask kills the frictionless pre-sale flow.
- **❌ Letting the corpus sitemap point at routes that don't exist.** Verify against `glob src/app/*/page.tsx`; a bot that links 404s erodes trust.
- **❌ No global rate-limit bucket.** Per-IP alone lets a rotating-IP botnet drain admin keys overnight.
- **❌ Skipping a re-sync after a pricing/feature change.** The bot is only as truthful as its last sync (§10).

---

## 12. Kickoff prompt

> Execute the Pre-Sale Chatbot Playbook at `<workspace>/templates/a-tier/PRE-SALE-CHATBOT-PLAYBOOK.md` for **<APP NAME>**. Install the grounded pre-sale chatbot end-to-end and auto-build its knowledge base from the app itself.
>
> Inputs:
> - **App name / product one-liner:** <…>
> - **Assistant name:** <e.g. "Rozy AI">
> - **Live marketing domain:** <https://…>
> - **Repo path:** <<workspace>/Active/…>
> - **Key source:** admin console (`getSandboxTextKeys`) OR env vars — <which>
> - **Provider:** OpenAI primary, Anthropic fallback (default)
>
> Steps:
> 1. **Autobuild the corpus (§3):** WebFetch the live homepage, /pricing, /signup, and the footer/nav subpages; read the repo's plan/credit/feature/refund config; reconcile (code wins for exact numbers, mirror the site's public tables verbatim); write `src/lib/help/public-corpus.ts` with the canonical article taxonomy. Flag any site↔code drift in your report.
> 2. **Install** the route, widget (+ lazy + gate), and types; mount the widget in `layout.tsx`; set the assistant name + brand in both.
> 3. **Wire** `proxy.ts` `PUBLIC_API_PREFIXES`, the two rate-limit buckets, and confirm a valid OpenAI key sits in a text slot (or env).
> 4. **Typecheck** with `node node_modules/typescript/bin/tsc --noEmit`, run the §9 smoke test, then **pause for review** before committing.
>
> Reference the BKE gold-standard files (read them, adapt — don't re-derive):
> - `src/lib/help/public-corpus.ts`
> - `src/app/api/public-chat/route.ts`
> - `src/components/landing/LandingChat.tsx`

---

**Reference files in BKE:**
- `src/lib/help/public-corpus.ts` — the knowledge base (gold-standard shape + tone)
- `src/app/api/public-chat/route.ts` — streaming endpoint, OpenAI-primary, system-prompt contract, SSE translation
- `src/components/landing/LandingChat.tsx` — widget UI, thread persistence, link rewriting
- `src/components/landing/LandingChatLazy.tsx` — lazy-mount wrapper
- `src/lib/help/content.ts` — `HelpBlock` / `HelpArticle` types
- `src/lib/admin-keys-server.ts` — `getSandboxTextKeys()` admin key resolution
- `src/lib/rate-limit.ts` — Upstash sliding-window enforcer
- `src/proxy.ts` — `PUBLIC_API_PREFIXES` edge allowlist
- `src/app/layout.tsx` — where the widget mounts

---

**Built:** 2026-06-24. **Reference implementation:** BKE / Kompozy "Rozy AI" (kompozy.io). **Cross-refs:** DEMO-SANDBOX-PLAYBOOK (Pattern B / shared anon infra), ADMIN-CONSOLE-PLAYBOOK (enterprise key bridge), CUSTOMER-ANALYTICS-PLAYBOOK (track chatbot → signup conversion).
