An agent knowledge base that stops giving wrong answers
Wrong answers from your chatbot are not a model problem. They are a content problem. Move the knowledge into one governed Sanity dataset, and here is how.

Jarod Reyes
Head of Developer Experience & Community at Sanity

Adam Gray
Senior Solution Architect at Sanity

Tormod 'Tor' Flesjø
Senior Solution Architect at Sanity

Noah Gentile
Principal Solution Architect at Sanity
Published:


Table of Contents
- For the Head of Customer Experience
- The structural shift
- Sanity Context
- Private dataset, server-side tokens, no exceptions
- Portable Text, not raw HTML
- Separate external and internal types
- Automatically reviewing content with a function
- Hybrid retrieval with one query
- Try it
- Where this goes next
- Related guides
The Head of Sales Enablement at Klaviyo described the problem like this. The same product fact lives in Zendesk, Salesforce, the wiki, and a deck somebody updated last quarter. They all say slightly different things. "This exact thing has kept me up for eight years."
That was a content fragmentation problem before AI showed up, but now it’s a critical blocker. When you point a chatbot at a help center built for humans, it does not triangulate between the wiki and the policy doc and the help article. It picks an answer and ships it. When the answer is wrong, it is wrong in front of a customer.
The fix is not a better model (though that certainly helps). It is a better content source. What you need is one governed dataset that customer-facing agents and internal teams both query, with the freshness and access boundaries the editorial team can actually maintain.
For the Head of Customer Experience
Three things to know before you keep reading or hand this to your engineers.
If you tried to build a chatbot already it probably isn’t the models fault if it doesn’t work. A model can only answer from what it is given. If your help center is HTML, your escalation paths are in Confluence, and your billing policy is in a shared drive, then the agent only gets it right when those all agree and are maintained with precision. But migrating those three assets into one governed source removes the need for the agent to guess.
Two agents from one dataset. The external chatbot on the help center sees published help articles and FAQs. The internal app your reps use sees those plus the playbooks and policies. Both queries hit the same live content. When the billing team updates a policy in the morning, the agent reflects it in the next query, without a need for a restart, forced update or sync.
The numbers in market. Braze ran an agent knowledge base on this exact pattern and said 90% of the build was done by Claude Code in a "one-shot" because the architecture made it implementable. Their non-technical CS, UX research, and product marketing teams now configure their own agents from Studio without filing engineering tickets. We’ve talked to customers who are migrating 88+ support articles from Zendesk to this model because Zendesk's search is essentially "a word search and that's it." 73% of developers in a recent survey cite headless CMS as their primary agent knowledge source. The pattern is not theoretical.
Where the work lands. Engineering builds the framework once: schema, two Sanity Context configs, private dataset, server-side tokens, two surfaces. Editorial owns content freshness in Studio with a "needs review" queue and a 90-day clock that resets on publish. Compliance-sensitive changes route through Content Releases. And with that, you’ll have a reliable real-time agent with answers for your team and customers.
The rest of this is for your engineers.
The structural shift
The conventional way to build an AI knowledge base is to scrape your help center, embed it in a vector database, glue a model on top, and pray to the demo gods. This is the RAG stack: LangChain plus Pinecone plus a sync job your team now owns.
In this guide we are going to assume you can migrate all of your content into Sanity (for now this is the way). By putting our knowledge base into a typed, structured CMS, the agent can query the live content graph directly. This requires some setup but it’s well worth it. The query happens in real time against the same content the editors are editing.
That alternative needs three things to be safe and correct. A scoped MCP endpoint per audience. A boundary between external and internal content that does not depend on remembering to add a filter. And a freshness mechanism the editorial team owns by default. The starter ships those three things, let’s walk through each.
Sanity Context
Sanity Context is a hosted MCP endpoint Sanity exposes from a configuration document. The config is a typed Sanity document with two fields that matter: a GROQ filter that scopes what content the agent can see, and an instructions field that tells the model how to query and present that content.
The starter ships two of these documents.
// studio/seed/sanity-context-external.ts
{
_type: 'sanity.agentContext',
title: 'Customer Support',
groqFilter: '_type in ["helpArticle", "faq", "product", "topic"]',
instructions: `
Rules
- Only answer from the provided content. If unsure, say so.
- Cite the article title and slug in every answer.
Schema notes
- helpArticle.content is Portable Text. Use pt::text(content) for plain text.
- audience is a multi-select: developer | admin | end-user | all.
Query patterns
- Filter by audience: && $audience in audience[]
- Filter by product: && $productSlug in products[]->slug.current
`
}// studio/seed/sanity-context-internal.ts
{
_type: 'sanity.agentContext',
title: 'Team KB',
groqFilter: '_type in ["helpArticle", "faq", "playbook", "policy", "product", "topic", "internalCategory"]',
instructions: `
Rules
- Answer customer-facing questions from helpArticle/faq.
- Answer internal procedure questions from playbook/policy.
- Surface importance: critical first.
Schema notes
- playbook and policy are internal-only. Never quote them to customers.
- reviewByDate: warn the user if the document is past review.
`
}Two configs because the boundary between what customers see and what reps see is hard, non-negotiable, and should be enforced at the schema level, not at the query level. A single config with conditional filter logic is one missing condition away from leaking the escalation playbook into the customer chatbot. Two endpoints with two server-side tokens makes the boundary structural.
Each config produces its own stable MCP URL. The Next.js help center wires the external URL. The App SDK internal tool wires the internal URL. Same dataset, different agents, different audiences.
Private dataset, server-side tokens, no exceptions
The GROQ filter on a Context document is a content governance mechanism, not a security boundary. If the dataset is public, anyone with the MCP URL can bypass the filter and query the whole dataset directly.
Set the dataset to private. Provision two read tokens, one per surface. Hold both tokens server-side. The browser never sees them.
// app/src/app/api/chat/route.ts (external help center)
import { createMcpClient } from '@ai-sdk/mcp'
const mcp = createMcpClient({
url: process.env.AGENT_CONTEXT_EXTERNAL_URL!,
headers: { Authorization: `Bearer ${process.env.SANITY_READ_TOKEN_EXTERNAL}` }
})// dashboard-server/src/chat-proxy.ts (internal tool)
const mcp = createMcpClient({
url: process.env.AGENT_CONTEXT_INTERNAL_URL!,
headers: { Authorization: `Bearer ${process.env.SANITY_READ_TOKEN_INTERNAL}` }
})The internal tool is built with the App SDK, which runs in the browser. The starter solves the "App SDK is browser-only but the token must stay server-side" problem with a small chat proxy (dashboard-server/) that holds the internal token and brokers requests to Sanity Context. The browser app talks to the proxy. The proxy talks to Sanity. The token is safe and sound on the server.
If you skip this step, the GROQ filter does not protect anything. Curl the MCP URL with no token and it returns nothing. Curl with the leaked browser token and it returns whatever the filter says, which is "internal policies" if the internal token leaks.
Portable Text, not raw HTML
There are three problems with raw HTML article bodies. HTML tags create noise in the embedding vector. Article title matches outrank body content. Per-article embedding counts get inconsistent. This will leave you looking for any possible solution to strip the HTML before the embeddings are done.
Portable Text removes the preprocessing step entirely. GROQ ships a built-in plain-text extractor that walks the Portable Text tree and returns a clean string suitable for embeddings:
*[_type == "helpArticle" && slug.current == $slug][0] {
title,
summary,
"content": pt::text(content)
}Same field powers two things. The rendered help center page reads content as Portable Text and renders it with full editorial control over headings, lists, callouts, code blocks, and custom components. The semantic index reads pt::text(content) and gets a clean string with none of the HTML noise.
The choice has migration consequences. Content imported from Zendesk or another HTML-first system has to be re-authored or run through a GROQ mutation to land as Portable Text. This starter ships a Beacon SaaS seed dataset already in Portable Text so you can see the shape before you migrate anything.
Separate external and internal types
Resist the urge to model this as one article type with a visibility flag. The reason is the same reason the two Context documents are separated. If visibility: internal is the only thing protecting the escalation playbook, every query author has to remember to include the filter. They will forget.
Model the boundary into the schema:
// studio/schemas/helpArticle.ts (external)
defineType({
name: 'helpArticle',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
defineField({ name: 'slug', type: 'slug' }),
defineField({ name: 'summary', type: 'text' }),
defineField({ name: 'content', type: 'array', of: [{ type: 'block' }] }),
defineField({
name: 'audience',
type: 'array',
of: [{ type: 'string' }],
options: { list: ['developer', 'admin', 'end-user', 'all'] }
}),
defineField({ name: 'products', type: 'array', of: [{ type: 'reference', to: [{ type: 'product' }] }] }),
defineField({ name: 'topics', type: 'array', of: [{ type: 'reference', to: [{ type: 'topic' }] }] }),
defineField({ name: 'reviewByDate', type: 'date' }),
defineField({ name: 'owner', type: 'reference', to: [{ type: 'person' }] })
]
})
// studio/schemas/playbook.ts (internal)
defineType({
name: 'playbook',
type: 'document',
fields: [
// ...same shape as helpArticle...
defineField({
name: 'importance',
type: 'string',
options: { list: ['standard', 'critical'] }
}),
defineField({
name: 'internalCategory',
type: 'reference',
to: [{ type: 'internalCategory' }]
})
]
})The external Sanity Context's GROQ filter is _type in ["helpArticle", "faq", "product", "topic"]. The internal Context's filter extends the list. The boundary is the type, which is a property of the schema. It cannot be set wrong on a document because it is not a field.
Automatically reviewing content with a function
Knowledge gets stale on its own. The editorial team has to maintain freshness somehow, or the agent ends up citing the November billing policy in February.
A Sanity Function fires on every publish event and sets reviewByDate to 90 days from now if it is not already set further out.
// functions/on-publish-set-review-date/index.ts
import { documentEventHandler } from '@sanity/functions'
export const handler = documentEventHandler(async ({ event, context }) => {
const reviewableTypes = ['helpArticle', 'faq', 'playbook', 'policy']
if (!reviewableTypes.includes(event.data._type)) return
const ninetyDaysOut = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)
.toISOString().split('T')[0]
const current = event.data.reviewByDate
if (current && new Date(current) > ninetyDaysOut) return
const client = createClient({ ...context.clientOptions })
await client.patch(event.data._id).set({ reviewByDate: ninetyDaysOut }).commit()
})A Studio Structure view surfaces documents whose reviewByDate has passed, with a "needs review" badge. The editorial team sees the stale queue on first open. No ticket, no spreadsheet. The agent's accuracy now tracks the content team's diligence, not an engineering sync schedule.
For compliance-sensitive content, route changes through a Content Release. The agent only sees the published dataset, so a staged release holds the change out of the agent's context until compliance approves and publishes it.
Hybrid retrieval with one query
The starter enables Dataset Embeddings on the dataset, which computes embeddings for every document on publish. A single GROQ query does both filtering and semantic ranking:
*[_type in ["helpArticle", "faq"] && $audience in audience[]]
| score(text::semanticSimilarity("content", $query))
| order(_score desc)
[0..5] {
_id, _type, title, summary, slug,
"products": products[]->{ title, slug },
"topics": topics[]->{ title, slug }
}The text::semanticSimilarity() function scores each document against the user's query using the embedding for the content field. The structural part of the query (_type in [...], audience filter) precedes the semantic ranking, which means the model never sees irrelevant content types or wrong-audience content in the first place.
This is the architectural argument against a DIY RAG stack. Most RAG implementations do structural filtering in application code after the vector search returns results, which means the vector search itself is operating over a larger, noisier corpus. GROQ does the structural filter first, then the semantic scoring, in one query, against the live dataset.
Embedding recompute lag is about a minute on publish. Acceptable for almost every use case. If your team needs tighter freshness, a Function on publish can pre-warm a cache or send a notification to the agent surface.
Try it
pnpm create sanity@latest --template sanity-labs/starters/knowledge-base cd your-project pnpm install # Copy each .env.example to .env and fill in values pnpm bootstrap pnpm dev
pnpm bootstrap deploys the blueprint and schema, enables Dataset Embeddings, generates types, and imports the Beacon SaaS seed data: 15 help articles, 10 FAQs, 8 playbooks, 5 policies, plus the taxonomy. Studio runs at localhost:3333. The external help center runs at localhost:3000. The internal App SDK app is in dashboard/ with its chat proxy in dashboard-server/. Ask the external chatbot a billing question and ask the internal app the same question. Two different answers from the same dataset, scoped correctly by Agent Context.
The starter is at sanity-labs/starters/knowledge-base.
Where this goes next
The pattern Braze ships in production has a fourth piece worth flagging. A Studio agent document type with fields for name, description, systemPrompt, and agentContexts[]. Non-technical leads (CS, UX research, product marketing) create their own scoped agents from Studio, pick which Context they want, write a system prompt, and get a working chat interface wired to the document. Engineering builds the chat interface once. Teams operate independently.
The starter does not ship this yet because the pattern is still settling, but every part of the foundation is in place. Two Agent Contexts. Private dataset. Server-side tokens. Hybrid retrieval. Once the framework is up, adding the configurator pattern is a small schema and a thin UI on top of what you already have.
That is the secondary reason to build this now. The primary reason is your customers stop getting wrong answers.