# Kickoff — website chat (`web` channel)

> The work order for the `channels/website-chat` lane. Written 2026-08-03 after
> an operator grill. **This supersedes the pre-plan seed in
> `plans/kickoff-prompts/website-chat.md`** (contract: the opener holds mission,
> this file holds the plan). Lane state: `plans/rethink/website-chat-handoff.md`.

## Objective, one line

A chat widget on `silveroakglobal.com` where the same agent that answers DMs
answers visitors, can look things up in the CMS, can walk them to a page, keeps
a diary on the contact, and hands a qualified lead to the CRM — on a spend
budget it cannot exceed.

## Why this scope exists

It is the one channel Meta App Review does not gate (`HANDOFF.md:30`). Every
other channel is blocked on a Meta decision; this one is blocked on nothing.

---

## Verified ground truth (probed 2026-08-03 — do not re-derive)

**Ours**

- `ChannelId = "whatsapp" | "instagram" | "facebook" | "web"` already exists
  (`packages/shared/src/agent/types.ts:6`). **The channel is `web`. Never
  `website`** — a second token for one concept violates one-concept-one-surface
  and the meta→whatsapp naming rule.
- `web` already ships in `SITUATION_CHANNELS.dms` (`agent/situations-v2.ts:81`),
  the Inbox filter enum (`apps/api/src/inbox/dto.ts:13`), and user-facing copy:
  `channelName("web") → "The website chat"` (`shared/src/inbox/reply-window.ts:138`,
  pinned by a test). **The name is reserved; nothing is built behind it.**
- `gateAllows` hard-returns `false` for anything not whatsapp/instagram/facebook
  (`shared/src/agent/channel-gates.ts:241-246`). The agent core will refuse `web`
  until a gate + branch land. **This is Stage 0, not a surprise.**
- `Contact.attributes Json` (`schema.prisma:311`) + a custom-field definition
  model (`:373`) are the **existing** IG/FB mechanism for arbitrary per-contact
  facts. The "diary" needs no new model. `ConversationNote` covers free text.
- `Contact.channel` / `Conversation.channel` / `Message.channel` are plain
  strings (`schema.prisma:289,413,450`) — no enum, no migration for the channel.
- `Message.payload Json` (`:456`) exists — page URL, agent actions and lead
  outcome ride it. **No migration for the analytics record.**
- Reusable as-is: `@Public()` + global JWT guard, `ApiKeyGuard` with timing-safe
  hash compare (`apps/api/src/campaigns/api-key.guard.ts`), the in-app limiter
  `apps/api/src/auth/login-rate-limiter.service.ts`, `ConversationHistoryPort.
  recentMessages` (session-windowed history, `shared/src/agent/ports.ts:24`).
- CORS is **single-origin**, `credentials: true` (`apps/api/src/main.ts:33-38`).
  The public chat routes are same-origin via a rewrite, so this does **not**
  need to become an allowlist. Do not widen it.

**Theirs — the live site**

- `production-omega-rose.vercel.app` (→ `silveroakglobal.com`) is **prerendered
  static HTML on Vercel**, repo `aatifaneez/silver-oak-properties-main-website`,
  cloned locally at `dev/frontend-sop`. Built by `tools/prerender.mjs`.
- **CSP is strict and enforced** (live response header + `vercel.json:29`):
  `default-src 'self'`, `script-src 'self' 'unsafe-inline' +challenges.cloudflare.com
  +assets.calendly.com`, `connect-src 'self' +silveroak-nuevo-dashboard.vercel.app
  +silveroak-rebuild.vercel.app +challenges.cloudflare.com`, `frame-ancestors 'none'`.
  **Cloudflare Turnstile is already allowlisted** — bot defense costs no new origin.
- `assets/js/site-common.js` is loaded by **every** page, generated property
  pages included. One file boots the widget everywhere, forever.
- `vercel.json` already carries a `redirects` block (`:102`); adding `rewrites`
  is a known-shape edit to a file we can already see.
- The site runs its own Vercel functions (`api/apply.js`, `api/lead-confirm.js`)
  — precedent, but we are deliberately not adding a third.
- **No chat widget of any kind exists today** (grepped: chatbot/widget/livechat/
  intercom/tawk → zero hits).

**The CMS**

- `silveroak-nuevo-dashboard.vercel.app`, repo `ammarsilveroak/silveroak-rebuild`
  (ours), Next + Payload 3 on Postgres.
- `publishedOrAuth` returns a **Where query** for anonymous callers
  (`rebuild/src/access/index.ts:26-27`), so an unauthenticated read can only ever
  see published docs — a draft cannot leak, even on a by-slug lookup. Payload
  exposes REST per collection. **The public read surface already exists and is
  safe. No CMS code is written in this scope.**
- The CMS owns `leadIntake` → `sendToCrm` → LeadRat. **We also write LeadRat.**
  See decision 6.

**Not evidence:** `silveroak-rebuild/AUTOPSY.md`, `autopsy/*`, and
`production-omega-rose.vercel.app-audit/` describe the OLD site (operator
correction, 2026-08-03). Do not cite them as current facts.

---

## Locked decisions (operator, 2026-08-03 — "best for sustainability and capability")

| # | decision | why |
|---|---|---|
| 1 | Transport is a **Vercel rewrite** `/chat/*` → `https://channels.silveroakglobal.com/api/web-chat/*`. No proxy function, no shared secret in their repo | A hand-written function is a second codebase in a repo we do not own. A rewrite is config that never needs touching. Same-origin ⇒ first-party cookie **and** `script-src 'self'` covers the widget ⇒ **no CSP edit at all** |
| 2 | The frontend change is **one PR, two hunks**: `vercel.json` rewrite + ~4 lines in `assets/js/site-common.js` that inject `<script src="/chat/widget.js">` | Every page already loads that file. Future generated pages inherit it. Sized to be reviewed once by someone else and never revisited |
| 3 | Session = **opaque 128-bit id in an `HttpOnly; Secure; SameSite=Lax; Path=/` cookie**, server-issued, no data inside, rotated on contact capture | `HttpOnly` is unreadable by JS, so an XSS on a page carrying `'unsafe-inline'`, 22 inline blocks and Calendly cannot steal a conversation. A third-party cookie from `channels.` would be blocked by Safari/Firefox — the rewrite is what makes this possible |
| 4 | Site knowledge = **the CMS REST API, live, short cache**. Not a synced KB, not an MCP | Authoritative and cannot drift. MCP would add a process, a client and a failure mode to reach the same HTTP API — machinery for one caller |
| 5 | **One `Contact` per anonymous session** (`channel:"web"`, `channelUserId` = session id), merged into the real contact when a phone/email arrives; unclaimed rows pruned | The Inbox and the diary both need a row. Merge keeps one identity per person across channels |
| 6 | **Our platform is the only LeadRat writer** for chat leads. The CMS `leadIntake` path is untouched and unused by chat | Two writers produce duplicate leads that are invisible to both systems |
| 7 | Contact details are asked for **after intent is shown**, never on open, and always before the budget close | Asking first kills the conversation; asking never wastes it |
| 8 | `web:chat` gate ships **off** | Same discipline as every channel. The operator flips it |
| 9 | An operator reply in the Inbox **reaches the widget** | Otherwise it is not one inbox |
| 10 | **EN + AR, with RTL, from the first commit** | The LLM is bilingual for free; retrofitting `dir` into a finished widget is a rewrite |
| 11 | **Short-poll** with a `since` cursor the contract keeps if SSE ever replaces it | One mechanism to maintain; upgrading later changes no client code |
| 12 | Page URL, agent actions and lead outcome recorded **per turn in `Message.payload`** | CMS analytics later is a query, not a migration |

**Accepted trade (named, not hidden):** the rewrite forwards to our VPS, so it
gives up the DoS shield a Vercel function would have provided, and the VPS has a
~1000-PID cap shared with live WhatsApp/Instagram. Mitigation lives in our code:
per-IP and per-session limits before anything expensive, and **Turnstile verified
before the first LLM call** — an unverified flood can cost DB writes, never tokens.

---

## Hard rails (not preferences — these are how the thing fails safely)

1. **The agent never emits a URL.** It emits a route intent (`show_property`,
   `open_page`) carrying a slug/id, which the widget resolves against a route
   table shipped with the site. Unresolvable ⇒ dropped and logged. An LLM that
   can emit URLs is an open redirect on the company's brand domain, reachable by
   anyone who types into the box.
2. **No secret ever ships in the widget.** The browser presents a public site key
   and a Turnstile token, nothing else. Authentication is origin + key + Turnstile.
3. **Zod at every public boundary**; every field length-capped. Fail closed.
4. **Additive migrations only.** This scope has exactly one (§ Stage 0).
5. **Spend is bounded before it is spent**, never reconciled after.
6. Every threshold, cap, prompt and message is **seeded data, not a constant**
   (project law: anything an operator might change is data).

---

## Stages — TDD, each with the oracle that closes it

**Stage 0 — vocabulary + gate.** `web:chat` added to `ChannelGates`
(off|shadow|live, ships off, fails closed); `gateAllows` learns `web`.
One additive migration: `ConnProvider` += `web`. Connection row holds site key
hash, allowed origins, Turnstile secret, CMS API base — encrypted store, hard
rule 1.
*Oracle:* `channel-gates` suite green including a new fail-closed test; migration
applies on a clean DB.

**Stage 1 — public endpoints, no agent.** `POST /api/web-chat/session`,
`POST /api/web-chat/message`, `GET /api/web-chat/messages?since=`. `@Public()`,
origin check, Zod, per-IP + per-session limits, Turnstile on first message,
cookie issued here. Echo replies.
*Oracle:* a curl conversation against the deployed API — session issued, message
accepted, echo returned; a 4th request inside the window gets 429; a request from
a disallowed origin gets 403; a missing Turnstile token never reaches an LLM.

**Stage 2 — persistence.** Contact + Conversation + Message rows on channel
`web`; page URL into `Message.payload`.
*Oracle:* SQL against the live DB shows the three rows with `channel='web'` and
the page URL on the turn.

**Stage 3 — the agent answers.** `run-turn` on `web` behind the gate, session-
windowed history, DM behaviour reused via channel targeting. No new Situations tab.
*Oracle:* a curl conversation returns a real agent reply; the WhatsApp goldens
stay byte-identical.

**Stage 4 — knowledge + navigation.** Agent tools reading the CMS REST API
(search properties, fetch one, fetch a page); route intents resolved against the
site route table.
*Oracle:* asking for a real published property returns its real facts; a crafted
"navigate to an external site" instruction is refused and logged.

**Stage 5 — budget and the graceful close.** Per-session turn cap, per-day site
token budget, both seeded. On cap: if contact details are held, close warmly and
escalate internally; if not, ask for a number or email, then close. Automated.
*Oracle:* a scripted session hits the cap and receives the in-touch message, not
an error; no LLM call is made after the cap.

**Stage 6 — capture → CRM.** Details written to `Contact.attributes` (the diary),
anonymous contact merged, one LeadRat lead.
*Oracle:* one lead in LeadRat, one contact, zero duplicates.

**Stage 7 — the widget.** Vanilla JS, no framework, no dependency (matches the
site's zero-dependency pattern). Survives navigation via the cookie, re-opens
mid-sentence. EN + AR, `dir` switch. Served from our origin through the rewrite.
*Oracle:* Playwright over the real static site with the rewrite stubbed — open,
send, navigate, the panel re-opens with history intact; screenshots at 390 and
1920, light and dark; RTL checked.

**Stage 8 — Inbox parity.** A web conversation looks and behaves like any other;
an operator reply reaches the widget.
*Oracle:* reply from the Inbox, widget shows it within one poll.

**Stage 9 — CMS capability (data only, no screens).** A scoped read-only key +
endpoint the CMS can call later. Screens are explicitly a later package.
*Oracle:* the endpoint returns a conversation list and transcript for a valid
key and 401s for an invalid one.

**Stage 10 — ship.** Backend deploy loop (`CLAUDE.md`), then the frontend PR.
*Oracle:* the scope oracle below.

---

## The oracle for the whole scope

A conversation held **through `silveroakglobal.com` itself** (not against our API
directly): the widget opens on a real property page, the visitor asks about that
property, the agent answers with facts from the CMS, walks them to another page,
the chat survives the navigation, contact details are captured, one LeadRat lead
and one diary entry exist, and the whole session is visible in the Inbox. Plus:
per-package tests green (full package, not a scoped subset), `pnpm build`,
health `{"status":"ok","db":true}` after deploy.

## Gate bars

1. The scope oracle above, run by the gate, not reported by the executor.
2. Independent **security** review over the landed diff — session handling,
   origin/CSRF, rate limits, prompt injection reaching a navigation or a tool,
   PII in logs, secrets. Operator asked for it explicitly.
3. Independent **quality** review over the landed diff.
4. Screenshots of the widget beside its intended design, both compared.
5. `web:chat` still `off` in prod at gate time.

## Out of scope

CMS screens (capability only). SSE. Voice. File upload. Any change to the CMS
repo. Rewriting the marketing site (analysed 2026-08-03: four of five variables
argue against; the CMS-coupling complaint is real and its cheap fix is the
existing rebuild trigger, one env var). Meta channels.

## Parked unknowns (`?` — never guessed)

- **?** Can we merge to `aatifaneez/silver-oak-properties-main-website`, or does
  Aatif ship every frontend change? Asked twice, unanswered. The plan is sized so
  the answer changes only who clicks merge.
- **?** Anonymous transcript retention period. Needed before Stage 9; a default
  of 90 days will be proposed with the privacy-page line.
- **?** Whether `privacy.html` on the live site already covers a stored
  conversation. To be read, not assumed.
- **?** `dev/ai-chatbot` (`ammarsilveroak/ai-chatbot`) is a second repo against
  the same brief with recent commits. Surfaced to the operator; nothing in this
  scope touches it.
