# Open-Sourcing ContextChat: A Docs Assistant Is Only as Good as Its Context

> Our docs assistant kept giving wrong answers with no citations. The problem was never the model - it was context. Here's how we built Sentra with whole-corpus RAG and grounded, cited answers, and open-sourced it as ContextChat.
- **Author**: Ayush Agarwal
- **Published**: 2026-07-01
- **Category**: Open Source
- **URL**: https://dodopayments.com/blogs/context-chat-open-source

---

Over a weekend, we built and shipped Sentra: the "Ask AI" assistant that now lives on our documentation. This week we open-sourced the engine behind it as ContextChat. The story of why is really a story about context.

Our docs run on Mintlify, and Mintlify ships with a built-in assistant. It was fine for simple lookups and frustrating for everything else. It gave wrong answers. It missed context that was sitting one page over. It cited nothing, so there was no way to check its work.

For most products a slightly-off docs answer is an annoyance. For a payments company it is a broken integration. A developer who is confidently told the wrong thing about webhook signature verification or refund timing does not file a support ticket. They ship the bug to production and find out when money moves the wrong way.

So we stopped trying to coax better answers out of a black box and built our own. The lesson we kept relearning along the way: a docs assistant is only ever as good as the context you put in front of the model.

## The Problem Was Context, Not the Model

It is tempting to blame the model when an assistant hallucinates. Swap in a bigger one, the thinking goes, and the wrong answers go away. They do not. We watched the same three failures show up regardless of which model sat at the end of the pipeline, and all three traced back to context, not capability.

### Problem #1: The Assistant Could Not See the Whole Corpus

The built-in assistant answered from a narrow slice of the docs - the current page, or a shallow index of a few. Our documentation is not written that way. The answer to "how do I handle a failed subscription renewal" lives across the subscriptions guide, the webhooks reference, and the dispute flow. Ask a question that spans pages and a narrow-context assistant fills the gaps with confident guesses.

### Problem #2: No Grounding Meant Confident Hallucination

Without retrieval tying every answer back to a specific source, the model does what language models do: it produces the most plausible-sounding text. Plausible is not the same as correct. For an API reference, the difference between a real parameter and a plausible one is the difference between working code and a 400 at runtime.

### Problem #3: No Citations Meant No Trust

Even when the built-in assistant was right, it could not prove it. No links, no sources. A developer who cannot verify an answer does the rational thing and goes back to reading the docs by hand, which defeats the entire purpose of having an assistant. An answer you have to double-check manually is slower than no answer at all.

## The Turning Point

The realization was almost annoying in how obvious it turned out to be. We had been evaluating models. The model was never the bottleneck.

Every failure above is a context-supply problem. The model was being asked to answer questions about documentation it had never been shown. Of course it guessed. Of course it could not cite. It did not have the material in front of it.

We did not need a smarter model. We needed to feed the model the right context, pulled from the entire documentation, ranked for the specific question being asked, with the source of every chunk carried along.

## The Mental Shift

```text
Old Model (a chatbot bolted onto the docs)
═══════════════════════════════════════════════════════════════════

  question ──► LLM ──► answer

  The model sees a narrow slice of context, or none.
  It guesses to fill the gaps, and cites nothing.

New Model (a retrieval problem with a chat interface)
═══════════════════════════════════════════════════════════════════

  question ──► retrieve the right context from the WHOLE corpus
                        │
                        ▼
                       LLM ──► answer grounded in that context,
                               citing the exact pages it used

  The answer's quality is decided BEFORE the model runs.
```

Once we framed it this way, the design fell out on its own. The interesting engineering is not the chat bubble and it is not the model. It is the pipeline that decides what context the model gets to see. Get that right and a modest model gives grounded, cited answers. Get it wrong and the best model in the world still hallucinates politely.

## The Architecture

### The Context Pipeline: Whole-Corpus RAG

The heart of Sentra is retrieval-augmented generation over our complete documentation. We index everything ahead of time, then retrieve the few chunks that actually answer each question at request time.

```text
The Context Pipeline: Whole-Corpus RAG
═══════════════════════════════════════════════════════════════════

  Ahead of time:
    contextmcp indexes the ENTIRE documentation into Pinecone
    as vector embeddings   (our open-source doc indexer)

  Per question:
    question
       │
       ▼
    vector search over the whole corpus
       │
       ▼
    reranking ──► the handful of chunks that actually answer
                  THIS question, each with its source URL
       │
       ▼
    prompt = grounding rules + retrieved chunks + question
       │
       ▼
    OpenAI, routed through Cloudflare AI Gateway
       │
       ▼
    streamed answer, citing the exact doc pages it used
```

Two choices in that pipeline do most of the work.

- **Indexing the whole corpus, not the current page.** Retrieval runs against every page of the documentation, so an answer can pull from three different guides at once. This is what killed the "missing context" failure. The context is no longer bounded by which page you happen to be reading.
- **Reranking after vector search.** Vector similarity gets you in the neighborhood. A reranking pass reorders the candidates by how well they actually answer the question, so the chunks that reach the model are the ones that matter, not just the ones that are lexically close. This is the difference between "relevant-ish" and "on point."

The indexer is a project of its own. We wrote about [why we built our own context layer](https://dodopayments.com/engineering/context-layer-ai-agents) - that engine, ContextMCP, is what populates the vector store Sentra searches, and it is open source too.

### Grounding and Citations

Retrieval only helps if the model is actually forced to use it. The system prompt instructs the model to answer strictly from the retrieved chunks and to say when the docs do not cover something, rather than inventing an answer. Every chunk carries its source URL through the pipeline, so when the answer streams back, it ends with citation chips that link to the exact pages the answer was built from.

That single change - every answer points at its sources - is what turned the assistant from "a thing that talks" into "a thing you trust." If the citation is wrong, you can see it immediately. If it is right, you stop double-checking.

### Retrieval Is an Interface, Not a Hardcode

Because retrieval is the whole game, we refused to weld it to one backend. The `/chat` handler asks an abstract retriever for relevant chunks and does not care where they come from.

```text
Retrieval is an interface, not a hardcode
───────────────────────────────────────────

   /chat handler
       │  query, limit
       ▼
   ┌────────────────┐   contextmcp    ┌───────────────────────┐
   │                │───────────────► │  ContextMCP /search   │
   │   Retrieval    │   http-json     │  (Pinecone + rerank)  │
   │   adapter      │───────────────► │  your JSON endpoint   │
   │  (pluggable)   │   http-markdown │                       │
   │                │───────────────► │  your text endpoint   │
   └────────────────┘                 └───────────────────────┘
```

The default adapter pairs with ContextMCP. If you already run your own search backend, a generic JSON adapter means you do not need ContextMCP to try ContextChat. The two are designed to pair, but neither is a hard dependency of the other.

### One Worker, One Script Tag

The whole thing is a single Cloudflare Worker that serves both the widget and the chat endpoint. There is no separate frontend deploy, no backend service to run alongside it.

```text
One Worker serves everything
─────────────────────────────────────────────────────────

  host page (our Mintlify docs)
    └─ window.ContextChat ──► loads widget.js
         └─ React widget, one self-contained IIFE
            mounted in a Shadow DOM
              │  POST /chat
              ▼
      Cloudflare Worker (this repo)
        ├─ retrieve:  contextmcp /search  (Pinecone + rerank)
        ├─ stream:    OpenAI via Cloudflare AI Gateway
        └─ SSE answer + citation chips
           (Vercel AI SDK for streaming, shadcn for the UI)
```

The widget is a React app compiled into one file with no runtime imports, and it mounts inside a Shadow DOM. That boundary matters more than it sounds. Mintlify has its own stylesheet, and without isolation the two CSS worlds collide - the docs repaint our widget, or our widget leaks out and repaints the docs.

```text
Why the widget lives in a Shadow DOM
──────────────────────────────────────

  Mintlify docs page
  ┌──────────────────────────────────────┐
  │  Mintlify CSS ── cannot reach in ──╳  │
  │                                       │
  │   #context-chat-host  (shadow root)   │
  │   ┌───────────────────────────────┐   │
  │   │  :host { --primary: ... }     │   │
  │   │  compiled widget stylesheet   │   │
  │   │  React app (shadcn components)│   │
  │   └───────────────────────────────┘   │
  │        ╳──── cannot leak out ────►     │
  └──────────────────────────────────────┘
```

All styles are injected into the shadow root, design tokens live on `:host`, and neither side can reach across the boundary. The answer streams over Server-Sent Events using the Vercel AI SDK, rendered incrementally with shadcn chat components, so text appears as the model produces it instead of arriving in one block at the end.

### Guarding a Public Endpoint

A docs assistant on a public page means a public `POST /chat` that streams tokens from a paid model. Public, unauthenticated, and expensive is a combination that invites abuse, so the endpoint runs every request through a stack of controls before a single token is generated.

```text
Every /chat call passes through, cheapest check first
═══════════════════════════════════════════════════════════════════

  1  CORS + origin allowlist    reject any Origin not listed
  2  Turnstile siteverify       drop bots that fail the check
  3  Per-IP rate limit          native Workers binding
  4  Global budget (Durable     hard daily cap on requests AND
     Object)                    tokens, across every IP
  5  AI Gateway spend limit     global dollar ceiling
```

The layers are ordered cheapest first, so the expensive work only runs on traffic the cheap checks already trusted. No single layer has to be perfect: Turnstile can be solved, origins can be spoofed by a non-browser client, IPs can rotate. But the global budget in a Durable Object collapses distributed abuse from any number of addresses into one daily counter, and the AI Gateway spend limit caps the dollar amount regardless. The worst case is "today's budget is spent," not "the invoice is unbounded."

## Open-Sourcing It as ContextChat

Sentra was hardcoded to us - our name, our theme, our prompt, our docs. Open-sourcing it meant turning every one of those into configuration so any documentation site could run the same engine.

Server-side identity (the prompt, the retrieval endpoint, the model, the allowlist) moved into Worker environment variables. Client-side presentation (the assistant name, the theme, the starter questions) moved into a single `window.ContextChat` object the host page sets before the script loads. Embedding it is one config object and one script tag:

```html
<script>
  window.ContextChat = {
    chatEndpoint: "https://your-worker.workers.dev/chat",
    assistantName: "Docs Assistant",
    starterQuestions: ["How do I authenticate?", "How do webhooks work?"],
  };
</script>
<script src="https://your-worker.workers.dev/widget.js" defer></script>
```

It ships under Apache-2.0. You deploy it on your own Cloudflare account, point it at your own docs, and bring your own model key. You control the data, the costs, and the model. Live Sentra is now just one deployment of ContextChat, configured with our values.

## Results

- **Grounded answers, every one cited.** Retrieval over the whole corpus plus a grounding prompt means answers point at the exact doc pages they came from. The "wrong answer, no citation" failure that started this project is gone.
- **Context is no longer bounded by the current page.** Vector search with reranking pulls the right chunks from across the entire documentation, so multi-page questions get whole answers.
- **One Worker, one script tag.** The widget and the endpoint are a single Cloudflare deployment. Embedding it is a config object and a `<script>` tag.
- **A public endpoint with a spend ceiling we chose.** Layered abuse controls plus a global budget in a Durable Object mean the worst case is a spent daily budget, not an unbounded bill.
- **Open source and self-hosted.** ContextChat is Apache-2.0. You run it on your infrastructure and keep control of your data, your costs, and your model.

## Should You Deploy This?

**Makes sense if:**

- You have real documentation and your built-in or off-the-shelf assistant keeps giving narrow, ungrounded, or uncited answers.
- You want retrieval over your whole corpus, with citations, rather than a chatbot that answers from a single page.
- You want to self-host and control the model, the prompt, the retrieval, and the spend, instead of handing all of that to a hosted vendor.

**Stick with the built-in assistant if:**

- Your docs are small enough that a single-page context window genuinely covers most questions.
- You do not need citations or grounding, and a plausible answer is good enough for your domain.
- You would rather not run a Cloudflare Worker or hold your own model key.

The honest summary: if your docs are small and your assistant is already good enough, keep it. We built ContextChat because our docs were large, the answers mattered, and "good enough" was giving developers confidently wrong information about payments.

## Key Takeaways

1. **A docs assistant is a retrieval problem wearing a chat interface.** The answer's quality is decided by the context you retrieve, before the model ever runs. Fix the context supply, not the model.
2. **Index the whole corpus, then rerank.** Whole-corpus retrieval kills "missing context," and a reranking pass after vector search is what turns "relevant-ish" chunks into the ones that actually answer the question.
3. **Citations are the trust mechanism.** An answer that points at its sources is one a developer will act on. An answer that cannot be verified sends them back to reading the docs by hand.
4. **Keep retrieval pluggable.** Welding your assistant to one search backend makes it a fork-and-rewrite for everyone else. An adapter interface makes it portable.
5. **A public LLM endpoint needs a spend ceiling you chose.** Layer your abuse controls cheapest-first and back them with a global budget, so distributed abuse resolves to a number you set in advance.

## What's Next

ContextChat is open source and available today under Apache-2.0.

```bash
# The widget + Worker
# github.com/dodopayments/context-chat

# Pair it with a retrieval backend
# github.com/dodopayments/context-mcp
```

Point it at a ContextMCP deployment, or any search endpoint you already run, set `window.ContextChat`, and drop one script tag on your docs. The full setup lives in the [project README](https://github.com/dodopayments/context-chat), and the [Dodo Payments docs](https://docs.dodopayments.com) are where Sentra, the deployment that started all of this, still answers questions.

We are already looking at the next pieces: a wider set of retrieval adapters, and better tooling for evaluating whether the retrieved context actually answers the question before it reaches the model. If there is a backend you want ContextChat to speak to, the repo is open and we read every issue.

_We're building payment infrastructure at Dodo Payments. If retrieval systems, applied AI, and fintech sound interesting, we're hiring._
---
- [More Open Source articles](https://dodopayments.com/blogs/category/open-source)
- [All articles](https://dodopayments.com/blogs)