Two AI assistants for a fictional software company, customer support and sales. Every answer is retrieved from the company's own documents, cited, and verifiable: click a citation to see the source, or open the Retrieval X-ray to watch the ranking itself.
Provable retrieval-augmented generation: each question is embedded and cosine-ranked against a 36-passage knowledge base, fused with a keyword ranking via reciprocal rank fusion (hybrid search), and the model answers only from the top passages with citations, behind guardrails that were red-teamed and are gated in the repo. The Retrieval X-ray tab exposes the ranking layer directly, scores included, no generation. Stack: vanilla-JS front end, serverless API on Vercel (SSE streaming, fair-use limits, server-owned persona), Google Gemini (embeddings and chat), honest "not in the knowledge base" fallback. The same retriever module runs in the page and on the server.
Two production-style assistants for a fictional SaaS product. Ask anything, click any citation to check the source, or open the Retrieval X-ray and watch the ranking itself, no generation involved. The Tested tab lists what broke and how it is guarded.
eval/gold.json at build time. The questions were written by the same person who tuned the retriever after reading the knowledge base, so they are an optimistic bound; a held-out protocol is described in the gold file. The hybrid number needs real embeddings and is measured with npm run eval:live.Anyone could send the chat API a message labelled as coming from the system and override the assistant's instructions, turning the demo into a free general-purpose model on the project's key.
api/chat.js passed client messages through untouched. v3.0 whitelists roles to user and assistant, drops everything else, and caps length and count.Before: attacker system message forwarded at index 1 of 2 system messages After: 0 client-controlled system messages; exactly one system message in every provider payload Gate: test/gate-endpoint.mjs: role whitelist Status: fixed
The red-team's first Critical fix (stop and point to emergency help if someone mentions self-harm) existed in the design doc and the offline prompt but had dropped out of the live sales prompt during a rewrite.
Personas now live in one file (backend/personas/personas.json) used by the server and injected into the page at build; a rule list (personas/rules.json) is asserted against every persona and the built page.Before: sales persona: 0 crisis clauses After: 9 required rules present in both personas and in the built page Gate: test/gate-static.mjs: persona rules Status: fixed
The 'edit the knowledge base' feature let a visitor push up to 36 edited passages of 1,500 characters each into the prompt with no marking, so 65% of what the model read could be visitor text.
Edits are now capped at 3 passages of 240 characters, control characters stripped, each labelled VISITOR-EDITED in the sources block, and the persona declares such passages untrusted data.Before: 4,494 of 6,931 system-prompt bytes (65%) visitor-controlled, unlabelled After: at most 3 x 240 characters, labelled; attacker share under 20% in the gate Gate: test/gate-endpoint.mjs: kbEdits caps and label Status: fixed
When the model provider rejected a request, its raw error text (which can include key fragments and internal details) was sent to the visitor.
Provider errors are logged server-side; the client receives fixed honest copy with a short error code (model_unavailable, provider_rate_limited).Before: HTTP 500 with the provider's 401 body including a key fragment After: HTTP 502/503 with fixed copy; provider text absent Gate: test/gate-endpoint.mjs: error copy Status: fixed
The assistants cannot take actions, but a reply like 'I've processed your refund' rendered with no warning, and one phrasing even produced an affirmative ticket card.
A completion-claim guard flags first-person action claims and passive completion phrasing; the ticket card renders only when the visitor asked to escalate; a distinct 'Handed off (illustrative ticket)' badge replaces the misleading 'Not in knowledge base' badge on escalations.Before: 20 of 20 phantom claims rendered with no specific warning; 1 got an affirmative card After: 20 of 20 flagged, 0 of 20 legitimate answers flagged, cards only with escalation intent Gate: test/gate-ui.mjs: claim guard 20+20 Status: fixed
Any website could call the demo's API from a visitor's browser, and the page shipped without the standard browser protections.
CORS now echoes only an explicit ALLOW_ORIGIN list (the same-origin page needs none). vercel.json is generated with a Content-Security-Policy whose script-src pins the SHA-256 of each inline script (computed by build.mjs), plus nosniff, frame-ancestors none, referrer and permissions policies.Before: access-control-allow-origin: *; no headers block After: no ACAO without an allowlist; CSP with per-script hashes; 5 protective headers Gate: test/gate-static.mjs: CSP hashes match built scripts; test/gate-endpoint.mjs: CORS Status: fixed
After 'Enter the demo' a phone visitor saw badges, picker cards and a tips popup; the chat was almost two screens down.
Under 640px the layout is chat first: compact hero, picker as a horizontal strip, sidebar after the chat, tips popup suppressed, min-width 0 on grid items.Before: chat top at 1,409px on an 844px viewport After: chat visible in the first screen on 390x844 (gate asserts chat top below viewport height) Gate: test/gate-ui.mjs: phone layout (Playwright) Status: fixed
README and design docs said the sales bot books demos, every answer is cited, a faithfulness check runs, the RAG toggle works, and the prompts are used verbatim. None of those were true of the deployed site.
README, backend README, DEPLOY-GUIDE rewritten; design docs and June test reports marked historical; the static gate forbids the specific overstatements.Before: 9 unsupported claims After: 0 forbidden claim strings in the docs (gate list) Gate: test/gate-static.mjs: forbidden doc claims Status: fixed
The project described itself as tested, but a clean clone had nothing to run.
Root package.json with npm test (build, static gate, endpoint gate, UI gate, retrieval eval, audit probe), declared devDependencies, GitHub Actions workflow.Before: 0 test files, no package.json, no workflow After: 5 gates plus the eval and the probe run on every push Gate: npm test Status: fixed
A project rule forbids dashes in copy, docs and code comments; the repo had 271 across 19 files.
Full sweep; the static gate fails on any em or en dash in the repo.Before: 271 dashes in 19 files After: 0 Gate: test/gate-static.mjs: dash sweep Status: fixed
Ask Support a question, switch to Sales before the reply arrives, and the Support answer showed up in the Sales transcript.
send() captures the bot at send time and appends the reply to that bot's history; the DOM updates only if that bot is still active.Before: answer shown in the other transcript After: answer stays with the bot that was asked Gate: test/gate-ui.mjs: bot switch Status: fixed
Clicking a sidebar suggestion while a reply was pending overwrote whatever you were typing and sent nothing.
Suggestions route through ask(): if a reply is pending the suggestion is queued and runs after it; drafts are never overwritten.Before: draft replaced, suggestion dropped After: draft preserved, suggestion queued and sent Gate: test/gate-ui.mjs: queued suggestion Status: fixed
The RAG toggle and the API-key gear were visible but ignored when the backend was in use, and a TOP_K setting was read and never applied.
Both controls are hidden in backend mode (body.backend-mode); TOP_K is applied (default 3, max 6).Before: RAG off still cited; gear save changed nothing; TOP_K unused After: controls hidden; TOP_K respected Gate: test/gate-ui.mjs: dead controls; test/gate-endpoint.mjs: TOP_K Status: fixed
The support bot asks for an email on file and a workspace URL, but nothing on the page told visitors what to type.
A demo-account card in the support sidebar shows owner@acme-co.example and acme-co with honest copy that the check is illustrative and low-assurance.Before: 0 hints on the page After: demo values on screen next to the chat Gate: test/gate-ui.mjs: credentials card Status: fixed
Asking whether there is a phone number produced a ticket instead of the honest answer that support is by email, and the student-discount answer extended an institutional discount to students.
Persona rules added: a question about how to reach support is a question, not a request; published discounts are never extended to unnamed groups. Escalations now carry a Handed off badge instead of Not in knowledge base.Before: ticket 3 of 3; over-extension 2 of 3 (live) After: rules shipped; live repeats pending after deploy Gate: live matrix (manual, three repeats) Status: fixed in prompt, live verification pending
The fair-use limiter read the first value of x-forwarded-for, which a client can prepend to.
clientIp() prefers x-real-ip and otherwise the last hop of x-forwarded-for, the one the platform appends.Before: 25 requests with a rotating prefix, never limited After: limited at request 21 regardless of prefix Gate: test/gate-endpoint.mjs: limiter keying Status: fixed
Words like 'what' were not stopwords, so a passage headed 'What is included' could rank for 'what is your refund policy'.
Shared retriever with an extended stopword list and a rule that a single common term is not evidence; the same module runs on the server and in the page.Before: junk keyword-only chunk in top 3 After: 0 keyword-only junk for the gate query; page and server rank identically Gate: test/gate-endpoint.mjs: junk keyword; test/gate-static.mjs: injected retriever equals module Status: fixed
The model was inventing ticket numbers and kept reusing the same few.
The server generates a random NIM id per request and instructs the model to use exactly that id; the gate asserts two requests get different ids.Before: 4 distinct IDs in 10 escalations After: unique per request Gate: test/gate-endpoint.mjs: ticket uniqueness Status: fixed
There was no gold set and no published number; the page's suggested questions reuse knowledge-base wording, so any number on them would be optimistic.
eval/gold.json (30 items: paraphrases, vocabulary matches, and out-of-scope questions) runs through the shipped retriever; keyword-path recall is stamped into the page at build and labelled in-sample-adjacent; the hybrid number requires a live key and is reported as not yet measured until eval/run.mjs is run with API_KEY.Before: no gold set, no number After: gold set shipped, keyword-path recall stamped, held-out protocol documented Gate: test/eval-retrieval.mjs Status: partially fixed: hybrid number pending a live run
Anyone could post audio to the transcription API without loading the demo.
A short-lived page token (issued by GET /api/health, HMAC of a timestamp, 15 minute life) is required; daily ceiling lowered to 150 per instance. This is a friction control, not authentication, and is described as such.Before: anonymous POST accepted After: 401 without token; 200 with the page token Gate: test/gate-endpoint.mjs: voice token Status: fixed
Escape did not close the source drawer, there were no direct links to the X-ray or the evidence page, and the tips popup covered the phone layout.
Escape closes the drawer and the modal; #kb, #xray, #tested and #chat deep links skip the cover; tips are hidden under 640px.Before: drawer open after Escape; no deep links After: Escape closes; deep links work Gate: test/gate-ui.mjs: Escape and deep links Status: fixed
The page carried no version, the package version never changed, and the social card was generated by a script that was not in the repo.
version.json feeds the page stamp, the health endpoint, and the card; tools/og-image.py is committed; CHANGELOG.md added.Before: no stamp; generator absent After: v3.0.0 stamped; generator committed Gate: test/gate-static.mjs: version stamp and generator present Status: fixed
The page and README did not mention the Harlan & West project built with the same method.
Footer and README cross-link via the sisterUrl field in version.json.Before: no mention After: cross-linked Gate: test/gate-static.mjs: cross-link present Status: fixed
The README promised the X-ray scores the whole knowledge base; it showed ten passages.
The endpoint now returns all 36 passages with scores; the page shows ten and offers Show all.Before: 10 of 36 returned After: 36 returned, 10 shown, fold for the rest Gate: test/gate-endpoint.mjs: retrieve returns 36 Status: fixed
These demos auto-use the built-in model inside Claude/Cowork. To run anywhere else, add an API key (kept in memory only).