✦
GroundedBuilt by Emilio Rigales · portfolio demonstration

Provable RAG. Answers you can verify.

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 RAG Shows its sources Stays on the rules Available 24/7 Live & deployed

What to try

1Ask Support about refunds, HIPAA, or a broken integration.
2Click a citation to see the exact source document.
3Switch to Sales and ask about pricing or book a demo.
How it works (for engineers)

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.

"Nimbus" is a fictional company created for this demonstration. Please don't enter real personal or payment data.
✦
GroundedPortfolio demonstration

Provable RAG: retrieval you can watch, answers you can verify

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.

Provable RAGSemantic + keyword hybrid 36-passage KB · top-3Clickable citations Red-teamedGated and versioned
N

Assistant

Nimbus
…
How it was tested, and what broke. This demo was built AI-assisted; every behavior is specified, tested against a gold set, red-teamed, and explainable. On 2026-09-01 it went through a full audit (AUDIT-2026-09-01.md): every finding below was reproduced by execution, and a probe script in the repo re-runs them all and fails while any still reproduces. Version 3.0.0, built 2026-09-01.
24findings, 22 fixed
0.73keyword recall@3 (30 questions, in-sample-adjacent)
0.58keyword recall@3 on paraphrases
not yet measuredhybrid recall@3 (needs a live key run)
5/6out-of-scope questions abstained (keyword path)
Retrieval numbers come from the shipped retriever module run over 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.
CriticalG-01: Client system-role messages were forwarded to the model

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

CriticalG-02: The deployed sales persona had no crisis clause

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

HighG-03: Visitor knowledge-base edits could fill most of the system prompt

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

HighG-04: Provider error bodies were echoed to the browser

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

HighG-05: No guard against the assistant claiming it did something

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

HighG-06: Wildcard CORS and no security headers

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

HighG-07: On a phone the first screen had no chat

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

HighG-08: Docs claimed things the code did not do

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

HighG-09: The repo contained no tests and no CI

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

HighG-10: 271 em and en dashes, including the title

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

MediumG-11: Switching bots mid-reply misfiled the answer

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

MediumG-12: A suggestion clicked while busy erased the draft

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

MediumG-13: Controls that did nothing on the live site

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

MediumG-14: The identity check could not be completed by a visitor

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

MediumG-15: Over-escalation and a stretched discount

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

MediumG-16: Rate limit keyed on a client-writable header

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

MediumG-17: Question words polluted keyword retrieval

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

MediumG-18: Ticket IDs repeated across visitors

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

MediumG-19: Retrieval quality had no evidence

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

LowG-20: Voice endpoint usable as anonymous speech-to-text

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

LowG-21: Keyboard and navigation gaps

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

LowG-22: No version stamp and an unreproducible card image

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

LowG-23: No link to the sister project

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

LowG-24: X-ray copy overstated its scope

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

Deliberately still open: a live red-team pass on the v3.0 prompts, faithfulness and refusal numbers (needs a model budget), the held-out question set, and the live hybrid retrieval number.

How it works

1. Your question 2. Retrieve top passages from the knowledge base 3. Answer using only those, with [citations] 4. Honesty guards: claim guard, handed-off badge 5. Click any source to verify

Quick tips

  1. Pick a suggested question on the left.
  2. Click a [1] citation to verify the answer.
  3. Open Retrieval X-ray to watch the ranking itself.
  4. Edit a doc in the Knowledge Base: the answers change, this session only.