# Chat, Support & Live-Agent Stack

CoreReflex ships a fully in-house chat stack — grounded AI answers, human handoff,
support tickets, and a God-Mode live-agent cockpit — with no third-party chat or
realtime SDK, staying within the **no-new-deps** rule.

**On this page**

- [Overview](#overview)
- [Architecture](#architecture)
- [Data model (migration 027)](#data-model-migration-027_chat_selfhealsql)
- [Modules](#modules)
- [Grounded answer pipeline](#answer-pipeline-srcchatjs--answer)
- [Knowledge-base ingestion](#ingestion--train-on-all-real-data)
- [Realtime bus](#realtime-bus-srcrealtime-busjs)
- [Human handoff state machine](#human-handoff-state-machine)
- [HTTP surface](#http-surface)
- [God-Mode cockpit](#god-mode-cockpit-publicjsgod-chatjs)
- [Support tickets and the self-heal seam](#support-tickets-and-the-self-heal-seam)
- [Configuration / env](#env)
- [Tests](#tests)
- [Gotchas](#gotchas)

## Overview

The stack serves two audiences and feeds a downstream repair loop. **All four phases
are shipped:**

1. **Front-of-house** *(phase 1 — shipped)* — an anonymous marketing widget
   (pre-sales / Q&A) that answers from *all real CoreReflex content* and captures
   leads.
2. **Human handoff** *(phase 2 — shipped)* — a visitor clicks "Talk to a human"; the
   conversation flips to `human_requested`, the owner is emailed, and the visitor can
   keep typing live messages while waiting.
3. **Back-of-house live agent** *(phase 3 — shipped)* — the God-Mode cockpit in the
   dashboard lists pending handoffs, open tickets, and self-heal briefs over a live
   SSE stream; an admin takes over, replies in real time, and resolves.
4. **Self-healing** *(phase 4 — shipped)* — a chat bug report becomes a support
   ticket **and** an AI fix-brief row, worked by a human-in-the-loop Claude Code
   `/schedule` agent that opens a PR. See [self-healing.md](self-healing.md) for the
   full downstream loop (sync scripts, inbox files, PR reporting).

## Architecture

```
marketing pages + dashboard                 God Console (dashboard #god-view)
  public/js/chat-widget.js                    public/js/god-chat.js
        │ fetch/SSE                                 │ fetch + EventSource
        ▼                                           ▼
  src/chat-routes.js ──► src/chat-handoff-routes.js   src/admin-api.js ─► src/admin-chat-api.js
        │                        │                                │
        ▼                        ▼                                ▼
  src/chat.js (answer)     src/chat-handoff.js ◄──────────────────┘ (join/reply/resolve)
   │        │                    │        │
   ▼        ▼                    ▼        ▼
 chat-kb  vertex.js        realtime-bus  src/support.js ─► src/self-heal.js
 (hybrid   (Gemini SSE)    (conv:<id>,    (tickets)         (fix briefs → /schedule PR)
  RAG)                      admin:support)
```

- `src/api.js` dispatches `/api/chat*` to `handleChatApi` (`src/chat-routes.js`),
  which owns the raw `response` object for SSE and delegates handoff routes to
  `src/chat-handoff-routes.js`.
- `src/admin-api.js` dispatches `/api/admin/chat*`, `/api/admin/support*`, and
  `/api/admin/self-heal*` to `handleAdminChatApi` (`src/admin-chat-api.js`).
- Everything realtime rides one in-process SSE bus (`src/realtime-bus.js`) — no
  websockets, no external broker.

## Data model (migration `027_chat_selfheal.sql`)

| table | purpose |
|---|---|
| `chat_kb` | **global** RAG corpus (blog/docs/faq/pricing/case-studies/testimonials). `vector(1536)` HNSW + generated `fts` tsvector (GIN). NOT workspace-scoped. |
| `chat_conversations` | one row per session; `mode` (`ai` / `human_requested` / `human_active`), `status` (`active` / `archived`), plus handoff fields: `priority` (low/normal/high/urgent), `category`, `assigned_admin_id`, `visitor_email`/`visitor_name`, `human_requested_at`/`human_joined_at`/`resolved_at`. |
| `chat_messages` | transcript; `role`, `sender_type` (`user`/`assistant`/`admin`/`system`), `sources`, `feedback`, `tokens_used`, `admin_id`. |
| `chat_settings` | KV overrides for the coded defaults in `src/chat-config.js`. |
| `support_tickets` / `support_messages` | tickets (optionally born from a chat — `source_chat_conversation_id` is UNIQUE, so one chat mints at most one ticket). |
| `self_heal_briefs` | the AI fix-brief + its lifecycle (queued → in_progress → pr_open → done / dismissed). |

## Modules

| file | responsibility |
|---|---|
| `src/chat-kb.js` | enumerate + chunk + embed all content (`ingestAllContent`); hybrid retrieval (`searchChatKb` = semantic ∪ lexical). |
| `src/chat-config.js` | default personas / greeting / limits (`CHAT_DEFAULTS`) + `chat_settings` overlay (`getChatSettings` / `setChatSetting`). |
| `src/chat-safety.js` | secret redaction (in + out) + advisory moderation. |
| `src/chat.js` | conversation/message persistence + the grounded `answer()` pipeline; re-exports the handoff verbs from `chat-handoff.js`. |
| `src/chat-handoff.js` | the handoff state machine: `requestHandoff` / `cancelHandoff` / `adminJoin` / `liveMessage` / `resolveConversation` / `createTicket`; each verb persists, publishes realtime events, and (for handoff) emails the owner. |
| `src/chat-handoff-routes.js` | public HTTP surface for handoff + live messaging + visitor SSE stream + create-ticket (session- or admin-authorized). |
| `src/admin-chat-api.js` | God-Mode HTTP surface: `/api/admin/chat*` (incl. the admin SSE stream + settings), `/api/admin/support*`, `/api/admin/self-heal*`. Privilege-gated; impersonating admins are blocked. |
| `src/support.js` | support-ticket CRUD + threaded `support_messages`; `createTicketFromConversation` snapshots the chat transcript into the ticket description; bug-category tickets auto-mint a self-heal brief. |
| `src/self-heal.js` | fix-brief generation (`createSelfHealBriefFromTicket`, `buildSelfHealBriefMarkdown` with inferred suspected areas) + queue listing / status updates. See [self-healing.md](self-healing.md). |
| `src/realtime-bus.js` | in-process SSE pub/sub: channels `conv:<uuid>`, `admin:support`, `comp:<uuid>`. |
| `src/vertex.js` | `streamGeminiText` / `generateGeminiText` (Gemini on Vertex; SSE). |
| `src/chat-routes.js` | public HTTP surface (owns the raw response for SSE); delegates to `chat-handoff-routes.js`. |
| `public/js/chat-widget.js` | zero-dep launcher + panel on marketing pages + dashboard; streams answers, drives "Talk to a human", and switches to live-message mode during a handoff. |
| `public/js/god-chat.js` | the God-Mode cockpit UI (mounts into `#god-view` on `public/dashboard.html`). |

## Answer pipeline (`src/chat.js` → `answer()`)

1. Redact secrets from the user text; retrieve top-K from `chat_kb` (hybrid).
2. **Grounding gate** — if fewer than `min_sources` match or the best score is below
   `min_source_score`, return the honest no-answer message (no model call). This is
   what stops the bot inventing product claims.
3. Build a grounded system prompt (persona per `context_type` + context block +
   safety policy; signed-in users get name/plan appended).
4. Stream the Gemini reply (`streamGeminiText`, wrapped in `runTraced`); redact the
   final text; persist the assistant message with `sources` + `tokens_used`.
5. If the stream dies mid-flight, whatever the user already saw is persisted with
   `metadata.partial = true` before the error is rethrown, so transcripts never end
   on a dangling user turn.

## Ingestion — "train on all real data"

`chat_kb` is built from the file-based content (reusing `src/blog.js`,
`src/case-studies.js`, `src/testimonials.js`, `docs/**/*.md`, and the marketing HTML +
its JSON-LD `FAQPage`). Re-ingest is idempotent (delete-then-insert per `source_path`).

```
npm run db:migrate           # apply migration 027
npm run kb:chat              # (re)build the corpus (embeds each chunk)
node scripts/ingest-chat-kb.mjs --dry   # enumerate + report, write nothing
```

Embeddings are best-effort: a chunk still stores + stays FTS-searchable if Vertex is
momentarily down, so retrieval degrades gracefully rather than failing.

Each source is re-ingested inside a **transaction** (delete old rows → insert new): a
partial failure rolls back and the source keeps its previous rows (never left empty),
and one flaky source is skipped rather than aborting the whole run. Embeddings are
computed outside the transaction (best-effort). The summary reports `failed` sources.

## Realtime bus (`src/realtime-bus.js`)

A single in-process pub/sub built on `EventEmitter` — subscribers are HTTP responses
held open as SSE streams (`event:`/`data:` frames, 25s keepalive comments,
`x-accel-buffering: no` to defeat proxy buffering). Channel names are validated at
the seam:

| channel | shape | used by |
|---|---|---|
| `conv:<uuid>` | `conversationChannel(id)` | per-conversation handoff events: the visitor widget subscribes via `GET /api/chat/conversations/:id/stream`. |
| `admin:support` | `adminSupportChannel()` | the God cockpit firehose: every handoff verb publishes here too; subscribed via `GET /api/admin/chat/stream`. |
| `comp:<uuid>` | `compositionChannel(id)` | **reused beyond chat**: in-editor comments (`src/comments.js` publishes `comment` events) and live agent-run progress (`src/api.js` publishes `agent_*` events to the composition channel). Auth happens at the subscribing route, not in the bus. |

Events published by the handoff verbs: `handoff_requested`, `handoff_canceled`,
`admin_joined`, `live_message`, `conversation_resolved`, `ticket_created`. The bus is
**single-container state** — see [Gotchas](#gotchas).

## Human handoff state machine

`chat_conversations.mode` drives it; every transition appends a `system` message to
the transcript and publishes to both the conversation channel and `admin:support`:

```
        requestHandoff                adminJoin
  ai ────────────────► human_requested ────────► human_active
  ▲                        │                          │
  └── cancelHandoff ◄──────┘        resolveConversation (any mode)
                                      → status=archived, mode=ai
```

- `requestHandoff` sets `priority` (validated: low/normal/high/urgent), optional
  `category`, captures `visitor_email`/`visitor_name`, stamps `human_requested_at`
  (idempotent via `coalesce`), and emails the owner (`support_notify_email`,
  idempotency-keyed per conversation; email failure is logged, not fatal — the
  response reports `notified: false`).
- `adminJoin` assigns `assigned_admin_id`, stamps `human_joined_at`, and posts
  "`<name>` joined the conversation."
- `liveMessage` appends a live turn (visitor → `sender_type='user'`; staff →
  `role='assistant'`, `sender_type='admin'`, `admin_id` set, `metadata.live=true`)
  and fans it out on both channels.
- `resolveConversation` archives the conversation and resets mode to `ai`; the widget
  shows "The support conversation was resolved." A later message from the same
  session starts a fresh conversation (only `active` rows are reused).
- `createTicket` delegates to `support.createTicketFromConversation` and broadcasts
  `ticket_created`.

On the widget side (`public/js/chat-widget.js`): "Talk to a human" opens a small form
(email / name / reason) with two actions — **Request human** (`POST /api/chat/handoff`)
and **Create ticket** (handoff + `POST .../create-ticket`). Once in human mode the
widget opens the conversation SSE stream, renders incoming admin `live_message`
events, and routes the visitor's own sends to `POST .../messages` instead of the AI
pipeline. Reloading the page restores human mode from the persisted conversation
(`mode` starts with `human_`).

## HTTP surface

### Public — AI chat (phase 1, `src/chat-routes.js`)

| method | path | notes |
|---|---|---|
| GET | `/api/chat/config?context=marketing\|dashboard` | widget bootstrap; works without a DB (returns defaults). |
| POST | `/api/chat` | ask a question → **SSE** stream (`data: {type:'meta'\|'delta'\|'done'\|'error'}`). Rate-limited 12/min per IP. |
| GET | `/api/chat/conversations/:id/messages?sessionId=` | reload a transcript (session-, owner-, or admin-scoped). |
| POST | `/api/chat/feedback` | thumbs up/down on an assistant message. Rate-limited 30/min per IP. |

### Public — handoff + live messaging (phase 2, `src/chat-handoff-routes.js`)

All `:id` routes require session (`sessionId` matches `conversation.session_id`),
owner (`conv.user_id === user.id`), or admin (`support_debug`) authorization.

| method | path | notes |
|---|---|---|
| POST | `/api/chat/handoff` | create/reuse a conversation + request a human. Body: `{sessionId, conversationId?, contextType?, email?, name?, reason?, priority?, category?}` → `{ok, conversationId, mode, notified}`. Rate-limited 6/min per IP; volunteered emails feed lead capture. |
| GET | `/api/chat/conversations/:id/stream` | **SSE** — live handoff events for this conversation (`ready`, `live_message`, `admin_joined`, `conversation_resolved`, …). |
| POST | `/api/chat/conversations/:id/handoff` | request a human on an existing conversation. |
| POST | `/api/chat/conversations/:id/handoff/cancel` | back to `mode='ai'`. |
| POST | `/api/chat/conversations/:id/messages` | visitor live message during a handoff (max 4000 chars). |
| POST | `/api/chat/conversations/:id/create-ticket` | mint a support ticket from this chat (idempotent per conversation). |

### Admin — God Mode (phase 3, `src/admin-chat-api.js`)

Dispatched from `src/admin-api.js`. Gated by app-admin privileges — `support_debug`
for support operations, `god_mode` for settings + self-heal. Impersonating admins
get a 403 ("Return to your admin session…").

| method | path | privilege | notes |
|---|---|---|---|
| GET | `/api/admin/chat/stream` | support_debug | **SSE** — subscribes to `admin:support` (the cockpit firehose). |
| GET | `/api/admin/chat/settings` | god_mode | effective settings (defaults + overrides). |
| POST | `/api/admin/chat/settings` | god_mode | upsert `chat_settings` keys (validated by `setChatSetting`). |
| GET | `/api/admin/chat/conversations?mode=&status=&limit=` | support_debug | list, `human_requested` first, then `human_active`, then the rest. |
| GET | `/api/admin/chat/conversations/:id` | support_debug | conversation + up to 200 messages. |
| POST | `/api/admin/chat/conversations/:id/join` | support_debug | take over (`mode='human_active'`). |
| POST | `/api/admin/chat/conversations/:id/messages` | support_debug | staff live reply (max 4000 chars). |
| POST | `/api/admin/chat/conversations/:id/resolve` | support_debug | archive + back to `ai`. |
| GET | `/api/admin/support/tickets?status=&limit=` | support_debug | list tickets. |
| GET | `/api/admin/support/tickets/:id` | support_debug | ticket messages. |
| POST | `/api/admin/support/tickets/:id` | support_debug | patch subject/description/status/priority/category/contactEmail. |
| POST | `/api/admin/support/tickets/:id/messages` | support_debug | staff ticket note (max 8000 chars). |
| GET | `/api/admin/self-heal/queue?limit=` | god_mode | active briefs (`queued`/`in_progress`/`pr_open`). |
| POST | `/api/admin/self-heal/:id/status` | god_mode | update `status` and/or `pr_url` (GitHub PR URLs only; anything else stores `null`). |

## God-Mode cockpit (`public/js/god-chat.js`)

Loaded on `public/dashboard.html` (alongside the widget); it mounts once into the
`#god-view` section and renders four panels:

- **Live chat** — active conversations with pending/active counts, a **Take over**
  button (join), an inline reply form, and **Resolve**.
- **Support tickets** — open tickets with urgent count, inline staff reply, and
  **Close**.
- **Self-heal queue** — active fix briefs with severity/status, a PR link when
  `pr_url` is set, and **Done** / **Dismiss** actions.
- **Chat settings** — edit the dashboard/marketing system prompts and the widget
  greeting (`POST /api/admin/chat/settings`).

It opens `EventSource('/api/admin/chat/stream')` and refreshes all panels
(debounced 150ms) on any of the six handoff events; the stream auto-reconnects after
5s on error. All mutations go through the admin routes above with same-origin
credentials.

## Support tickets and the self-heal seam

`src/support.js` is the ticket system of record:

- `createSupportTicket` normalizes status/priority/category (aliases:
  `bug_report → bug`, `feature_request → feature`, `feedback → general`). **If the
  category resolves to `bug`, it immediately mints a self-heal brief** via
  `createSelfHealBriefFromTicket` (`src/self-heal.js`) — one brief per ticket,
  idempotent.
- `createTicketFromConversation` (used by the chat create-ticket routes) snapshots
  the conversation transcript (up to 200 messages, `[sender] text` lines) into the
  ticket description, inherits priority/category/visitor email from the
  conversation, and is idempotent per conversation (unique
  `source_chat_conversation_id`).
- The brief markdown carries Title/Severity/Reporter/Repro/Expected/Actual/
  Suspected Areas/Acceptance/Transcript sections; suspected areas are inferred from
  keyword heuristics over the ticket text (widget → `public/js/chat-widget.js`,
  chat/SSE → `src/chat-routes.js`, tickets → `src/support.js`, admin →
  `src/admin-chat-api.js`, billing → `src/billing.js`, …).

From there the loop leaves this stack: **[self-healing.md](self-healing.md)** covers
how queued briefs are materialized to `self-healing/inbox/*.md`
(`scripts/self-heal-sync.mjs`), worked by the `/schedule` agent into a PR, and
reported back (`scripts/self-heal-report.mjs` or the admin status route). Chat is the
front door of that loop: a visitor bug report becomes a ticket, a brief, and
eventually a human-reviewed PR.

## Env

- `DATABASE_URL` — required for conversations/persistence (config endpoint degrades
  without it).
- Vertex: `VERTEX_PROJECT` + `GCP_SERVICE_ACCOUNT_JSON` (answers) and the embeddings SA
  (ingestion) — same seam as the rest of the app.
- `SUPPORT_NOTIFY_EMAIL` — where handoffs / new bug tickets notify (defaults to owner;
  overridable at runtime via the `chat_settings.support_notify_email` key).
- `CHAT_STREAM_TIMEOUT_MS`, `CHAT_REQUEST_TIMEOUT_MS` — Vertex call timeouts
  (`src/vertex.js`; defaults 60s / 45s).
- `CHAT_KB_MIN_SIMILARITY` (default `0.35`) — cosine floor for a semantic hit to count
  as relevant. Below it, the chunk is filtered in SQL so off-topic questions retrieve
  nothing and the bot declines (the honesty gate). Raise for stricter grounding.

## Tests

All offline via injected deps; run with `npm test`.

| suite | covers |
|---|---|
| `test/chat-safety.test.js` | redaction + moderation. |
| `test/chat-kb.test.js` | hybrid retrieval + ingestion. |
| `test/chat.test.js` | grounded / no-answer / mid-stream-failure answer paths. |
| `test/chat-stream.test.js` | the SSE frame parser. |
| `test/chat-routes.test.js` | the public HTTP surface. |
| `test/chat-widget.test.js` / `test/god-chat.test.js` | widget + cockpit client behavior. |
| `test/support.test.js` | ticket CRUD + transcript snapshotting + the bug→brief mint. |
| `test/self-heal.test.js` / `test/self-heal-scripts.test.js` | brief generation, queue ops, sync/report scripts. |
| `test/realtime-bus.test.js` | channel validation, subscribe/publish, cleanup. |

## Gotchas

- **The realtime bus is in-process.** SSE subscribers and publishes live in one Node
  process (single Coolify container). Scaling the API to multiple replicas would
  silently split the bus — a visitor subscribed on replica A never sees an admin
  reply published on replica B. Any scale-out needs a shared broker behind the same
  `subscribeRealtime`/`publishRealtime` seam.
- **POST-based SSE.** `/api/chat` streams over a `fetch` body reader (POST needs a
  body, so not `EventSource`); the handoff and admin streams are GET and do use
  `EventSource`.
- **Answer flow ignores `mode`.** `POST /api/chat` always runs the AI pipeline; it is
  the *widget* that switches to the live-message route in human mode. A custom client
  posting to `/api/chat` during `human_active` still gets an AI answer.
- **`resolveConversation` archives.** The widget keeps its conversation id in
  localStorage, but `getOrCreateConversation` only reuses `active` rows — the next AI
  question after a resolve starts a fresh conversation (the `meta` SSE frame updates
  the stored id).
- **One ticket per conversation.** `source_chat_conversation_id` is UNIQUE; repeated
  create-ticket calls return the existing ticket instead of erroring.
- **Impersonation is blocked in the cockpit.** `requireAdmin` rejects impersonated
  sessions before checking privileges, so an owner viewing-as-user cannot accidentally
  act as staff.
- **`pr_url` is allowlisted.** Only `https://github.com/<owner>/<repo>/pull/<n>` URLs
  persist; anything else becomes `null` (no error).

Related: [self-healing.md](self-healing.md) · [architecture.md](architecture.md) ·
[data-model.md](data-model.md) · [security.md](security.md) ·
[configuration.md](configuration.md)
