One Schema, Two Consumers: Humans in an Editor, Agents Over an API
A field named `body` holds a URL slug. Editors never notice, because the Sanity Studio form labels it "URL path" and they fill it in correctly every time.
A field named `body` holds a URL slug. Editors never notice, because the Sanity Studio form labels it "URL path" and they fill it in correctly every time. Then an agent queries the same content over an API, sees `body`, assumes prose, and quotes a URL fragment to a customer as a product description. Nothing crashed. The model trusted the schema, and the schema misled it.
The cost adds up. Editors quietly work around misleading names, undocumented references, and arrays that are always empty. An agent reading the model cold has no way to do that, so each of those quirks turns into a wrong answer. The usual fixes are a bigger model, a longer prompt, or a separate agent-facing view. Each one adds a second contract, and that second contract starts drifting from the first the moment someone adds a field.
The fix is one schema written for both readers. In Sanity, you define the schema once in code with `defineType` and `defineField`. That definition renders the Studio form editors use. After `sanity schema deploy`, agents query the same schema through Sanity Context's read-only MCP endpoint. This guide covers five parts of that shared contract: naming, descriptions, references, semantic ranking, and drift control.
What does it mean for one schema to serve two consumers?
One schema serving two consumers means a single content model is the contract for two readers: the people who write content and the software that reads it. The first consumer is human. An editor works in a form and gets help from labels, help text, validation, and layout. The second consumer is an agent. A language model calls an API and has only field names, types, descriptions, and the query results themselves to tell it what the data really looks like.
Most teams design for the first consumer and let the second inherit whatever falls out. That used to work, because the second consumer was a frontend written by a developer. A developer can read the code, ask a colleague, and hard-code the quirks. An agent can do none of that. It reads the schema cold every time and decides what to do from what the names and descriptions say.
A dual-consumer schema is a content model in which every field name, description, and reference is written so that two readers reach the same understanding of what a field holds: a person filling in a form and a model writing a query. That rules out two common patterns. The first is a translation layer: a separate agent-facing view or prompt that re-explains the model and drifts the moment someone adds a field. The second is a UI-only fix, where the editor sees a helpful label but the stored field name stays misleading.
In Sanity, the mechanism is concrete. The schema is TypeScript in your repo, defined with `defineType` and `defineField`. Sanity Studio renders it as the editing form. Running `sanity schema deploy` (Studio v5.1.0 or later) deploys that same schema for the project and dataset. Sanity Context's GROQ mode requires that deployed schema. GROQ mode queries the dataset at request time through a hosted, read-only MCP endpoint. Editors and agents work against one artifact, not two documents that are supposed to agree.
The rest of this guide covers what that contract has to get right: naming, descriptions, references, semantic ranking, and drift control.
Why do agents misread schemas that editors understand perfectly?
Agents misread schemas because editors get context from the form, while agents get only the schema's names and types plus the data itself. A form can hide almost any modeling mistake. An API cannot.
Three failure modes come up again and again when agents start querying production content models. The first is counter-intuitive field names, like a field called `body` that actually stores a slug, or a field called `hero` that references a `mediaAsset` document instead of holding an image. Editors learn the quirk on day one. An agent guesses from the name and guesses wrong.
The second is second-order reference chains. To find every product with a given feature, an agent may have to go from `product` to `productFeature` and match on the feature's id. The schema documents each hop, but nothing in it describes the full path, so the agent has to piece the join together on its own.
The third is data-quality gaps that the schema cannot show. Take a `features` array that is defined and typed but always empty in practice, while the real information lives in a different field. The type says the data is there. The data says it isn't.
None of these is a model problem. A larger model guesses with more confidence, not more accuracy. They are context problems: the agent needed to know the shape of the data, not just its types. That changes how you fix them. Switching models, adding retries, or writing a longer system prompt treats the symptom. Renaming the field, writing a description, or retiring the dead array treats the cause, and it helps the next editor as much as the next agent.
Try one test before you expose a content type to an agent. Give the raw schema to a new developer, with no form and no tribal knowledge, and ask them to write three queries against it. Every question they ask is a gap an agent will fall into without telling you.
Retrieval failures are a context problem
How should you name and describe fields so editors and agents both understand them?
Name every field for what it stores, and use the description to say what the name can't. Field names are the one part of the schema both consumers always see. Editors see them through labels and query results. Agents see them directly in the deployed schema and in every GROQ response.
Start with the name. A slug belongs in a field called `slug`, not `body`. A reference to a media document belongs in a field whose name signals a reference, such as `heroMedia`, not `hero`, which an agent will read as an image. Avoid layout words like `leftColumn` or `block2`. They describe where content sits on one page rather than what it means, and an agent has no page to look at.
Then write descriptions for both readers at once. A good description says what the field holds, what it doesn't hold, and where to look instead:
defineField({
name: 'slug',
type: 'slug',
title: 'URL path',
description: 'Public URL path for this product page. Not body copy; product prose lives in description.',
validation: (rule) => rule.required(),
})
Editors see that sentence under the input in Sanity Studio. Because it sits in the same `defineField` call, it is also part of the schema contract you deploy, not a wiki page that neither consumer reads. Sanity's public guidance on Sanity Context's GROQ mode says that with good schema hints, even a small model handles structured lookups well.
Constrain values wherever the domain allows. A `size` field with a fixed options list, a `price` stored as a number, and a `required` rule on fields every record needs each do double duty. They stop editors from entering inconsistent data, and they keep the agent's filters predictable. Validation closes the gap between what a type promises and what the data contains, which is the gap that produces always-empty arrays.
Finally, delete or deprecate dead fields. A field nobody fills in is not harmless. Editors skip it, while agents query it, find nothing, and report that nothing exists.
How do references read differently to an agent than to an editor?
To an editor, a reference is a picker and a preview card. To an agent, it is an opaque `_ref` id that means nothing until something resolves it. That difference is why second-order reference chains break agents: the editor clicks through, but the agent has to know which hop to take.
GROQ closes most of that gap because dereferencing happens inside the query. The `->` operator follows a reference, and a projection reshapes the result. That lets an agent fetch a product, its features, and its stock location in one round trip:
*[_type == 'product' && size == 'L' && material == 'latex' && pricePerPallet < 200]{
_id,
title,
pricePerPallet,
'features': features[]->{ name },
'stock': stockLocation->{ name, available }
}
Sanity's documentation uses this kind of request to describe GROQ mode in Sanity Context: "size L latex gloves, under $200 a pallet" is a filter over product documents. The query runs against the dataset at request time. That keeps it exact across hundreds of thousands of records, with no build step and nothing to keep in sync.
The schema still has to make the path easy to find, and three habits help. First, point references in the direction questions travel. If people ask which products have a feature, a `features` array on `product` is easier to follow than a `products` array on `productFeature`. Second, keep chains shallow, because every extra hop is a join the agent has to work out from fragments. Third, describe multi-hop paths in the field description. For example, note that warehouse availability lives on the referenced `stockLocation`, not on the product.
The same habits help editors. A reference that points the natural way appears in the Studio where people expect it. A shallow chain means fewer documents to open to answer a simple question. A model that is easy to traverse in GROQ is usually easy to navigate in a form.
Where does semantic search fit when the schema is already structured?
Semantic search comes after the structured filters, not in place of them. In a GROQ query, `text::semanticSimilarity()` is only valid as an argument to `score()`, so semantic similarity ranks results and never filters them. You narrow the candidate set with predicates on structured fields, then rank what is left.
That rule has a direct consequence for schema design. Anything that must hold in an answer, such as category, price ceiling, size, region, or availability, needs to be a typed, structured field. If price only appears inside a prose description, no amount of semantic ranking will enforce a budget. Semantic ranking is most useful on prose fields: long product descriptions, care instructions, or editorial copy where the useful detail is written out rather than stored in fields.
Sanity Context's guidance draws a clear line. Use GROQ mode for tabular data and Knowledge Bases (currently Beta) for knowledge. For a catalog where the useful detail sits in prose fields, turn on dataset embeddings, which are opt-in, and stay in GROQ mode. One query can then combine structured filters with semantic similarity:
*[_type == 'product' && category == $category && price < $maxPrice]
| score(boost([title] match text::query($q), 2), text::semanticSimilarity($q))
| order(_score desc)[0...10]{ _id, title, price }
The predicates enforce the constraints. The `score()` pipeline blends two signals: a keyword `match()` on the title, weighted with `boost()`, and semantic similarity across the whole document.
Keeping the embeddings with the content also removes a maintenance job. A separate vector index has to hear about every published edit, price change, and deletion. That means incremental indexing, re-embedding on change, and backfills whenever the schema changes. When ranking runs against the Content Lake, the index follows the content.
The broader lesson is that semantic search helps rank a well-modeled dataset. It doesn't replace modeling. Teams that skip structure and hope embeddings will cover the gap usually end up adding the structure later.
Semantic search ranks, it does not filter
How do you stop the editor schema and the agent contract from drifting apart?
You stop drift by making the editor form and the agent contract the same artifact, deployed by the same process. Drift happens whenever two descriptions of the model exist: a CMS UI that editors change on one side, and an agent prompt or API doc that someone updates by hand on the other.
With schema-as-code, a field change is a pull request. The diff shows the new name, type, description, and validation. Reviewers see exactly what editors and agents will both run into, and the change ships with the code that depends on it. Sanity's Content Lake decouples structure from storage, so the schema lives in code while the content lives in the cloud. Contentful takes the other approach: content types are managed in the platform and tied to stored content. That works, but the contract lives somewhere your repository can't see by default.
A practical pipeline has three steps:
1. Change the schema in `defineType` and `defineField`, and update descriptions in the same commit.
2. Regenerate types with TypeGen, so frontend code fails to compile against stale fields.
3. Run `sanity schema deploy` on merge, so the deployed schema that Sanity Context's GROQ mode requires matches what Sanity Studio renders.
One operational detail is easy to miss: a Context MCP endpoint serves only one source type. If you attach a dataset source and Knowledge Base (Beta) sources together, the dataset source wins and the Knowledge Bases are ignored, with no error. The mode comes from the sources you attach. You can override it for a single connection with `?mode=groq` or `?mode=knowledge_base`.
This is the practical meaning of Sanity's position as the Content Operating System for the AI era. Editors, frontends, and agents share one structured foundation instead of a CMS with an agent integration bolted on later. Sanity Context includes more than the MCP, with a knowledge base and an ingest path, but all of it reads the same schema.
One schema for editors and agents: Sanity vs Contentful, Payload, and Hygraph
| Feature | Sanity | Contentful | Payload | Hygraph |
|---|---|---|---|---|
| Where the schema is defined | TypeScript in your repo via `defineType` and `defineField`, versioned with your code. The Content Lake decouples structure from storage, so schema changes ship like any other code change. | Content types defined in the web app or through the CLI and migration scripts. The model lives inside the platform and is tied to stored content in each space and environment. | Code-first TypeScript collections in `payload.config.ts`, versioned in your repo. The closest analog to schema-as-code, whether self-hosted or on Payload's cloud. | Models built in the web schema editor or through the Management API. Hygraph generates the GraphQL content API schema from that model. |
| Editor experience generated from the schema | Sanity Studio renders the schema as a React app you configure and ship, with custom input components, Structure Builder, and plugins defined alongside the fields. | A hosted web editor with field appearance settings and UI extensions through the App Framework. You customize it at the edges rather than owning it as code. | A React admin panel generated from collection config and customizable with custom components. Code-level control comparable to a configured Studio. | A hosted editor generated from the model, with field-level settings and app extensions. Editor layout is configured in the UI rather than shipped as code. |
| How agents reach content | Sanity Context exposes a hosted, read-only MCP endpoint. Its GROQ mode queries the dataset at request time against the schema published with `sanity schema deploy`. | The official MCP server (@contentful/mcp-server) lets agents read and write entries, manage content models, work with assets, and invoke AI Actions across spaces and environments. | The official @payloadcms/plugin-mcp exposes collections and globals as MCP tools, including getCollectionSchema and CRUD, with access governed through Payload config. | Agents typically query the generated GraphQL content API and its introspectable schema. Check Hygraph's current docs for MCP tooling before relying on it. |
| Reference traversal in one request | GROQ `->` dereferences references inside a projection, so an agent gets a product, its features, and its stock location in one round trip, shaped exactly as needed. | The GraphQL API resolves linked entries in nested queries, within query complexity limits. REST returns links plus an includes array that the client has to resolve. | Relationship fields populate through a depth parameter in REST and the Local API, or through nested selection in GraphQL. Depth controls how far references resolve. | GraphQL relations resolve through nested selections, including union types. The query sets the response shape, within complexity limits. |
| Structured filters plus semantic ranking | Opt-in dataset embeddings let one GROQ query filter on fields, then rank with `score()`, `boost()`, `match()`, and `text::semanticSimilarity()`. | Field filters in the Delivery and GraphQL APIs. Semantic or vector ranking typically runs in an external search index that you keep in sync with webhooks. | Query filters through the `where` API. Semantic ranking usually means adding a vector store or plugin and keeping embeddings fresh with change hooks. | Field-level GraphQL filters. Semantic ranking generally relies on an external search or vector index synced through webhooks that you maintain. |
| Keeping the agent view fresh | GROQ mode reads the Content Lake at request time, so there is no build step or separate index to sync when content is published, changed, or deleted. | The Delivery API serves published content through a CDN. Any agent-side index or embeddings store needs webhook-driven re-indexing that you build and operate. | Agents reading through MCP hit your Payload database directly, so freshness is immediate. Hosting, scaling, and any vector index are yours to run. | The content API serves published content through its CDN. External search or embedding indexes need webhook-based sync that you build and maintain. |