Agent Frameworks

Improve accuracy of LangGraph agents with Sanity Context

LangGraph

Graph-based agent runtime for Python and JS with durable state, checkpointing, and human-in-the-loop interrupts for long-running, stateful workflows.

Visit LangGraph

Your LangGraph agent passes every eval in the notebook, then drifts the moment it hits production. The usual culprit isn't the model. It's that you stuffed everything into the graph's state: chat history, retrieved docs, the product catalog, the brand guidelines, all serialized into one channel that grows until the context window truncates the part the agent actually needed. The checkpointer faithfully persists the mess, so the next turn inherits it.

The fix starts with deciding what belongs in state and what belongs in retrieval. Ephemeral conversation memory belongs in the checkpointer. Authoritative content does not. That second category is where Sanity Context comes in. Sanity Context is Sanity's agent-facing product. Its primary surface today is Context MCP, a hosted, read-only MCP endpoint that exposes schema reads, GROQ queries, reference traversal, and optional semantic search across a Sanity dataset. Knowledge Bases is the second surface, for unstructured sources like PDFs, support exports, and websites.

This article keeps the framework discussion LangGraph-first: how state channels and checkpointers actually work, where they leak, and how to draw the line between memory and retrieval. Then it shows how to wire Context MCP as a tool node so the agent fetches structured content per turn instead of carrying it.

What LangGraph state actually persists, and where it leaks

LangGraph models your agent as a graph of nodes with a shared state object. You define the state shape with a TypedDict (Python) or an annotated channel set (JS), and each node returns a partial update that gets reduced into the running state. The checkpointer serializes that state after every super-step, which is what gives LangGraph its durability: interrupt a graph, resume it hours later, and the state is exactly where you left it.

The leak is a design problem, not a bug. Because the state object is the easiest place to put things, teams put everything there. A common pattern looks like a `messages` channel reduced with `add_messages`, plus a `context` channel where someone dumps the retrieved documents, plus a `catalog` field that got loaded once at graph start and never cleared. Every one of those channels is now part of the checkpoint. Every resume reloads them. Every LLM node that reads state sends them to the model.

The symptom shows up as token bloat and stale data. The catalog you loaded at the first turn is three releases out of date by turn twelve, but it's still in state, so the agent quotes a price that no longer exists. Meanwhile the `messages` channel keeps growing, and your `add_messages` reducer never trims, so by the time the conversation is interesting you're truncating from the front and losing the system instructions.

The rule that fixes most of this: state is for things that change per conversation. Anything that's authoritative, shared across users, and edited by someone other than the agent does not belong in a checkpointed channel.

A state graph that leaks: catalog baked into the checkpoint

The catalog and retrieved channels get serialized into every checkpoint and replayed on resume.

from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START
from langgraph.graph.message import add_messages
from langgraph.checkpoint.memory import MemorySaver

class State(TypedDict):
    messages: Annotated[list, add_messages]
    # These two channels are the problem:
    catalog: list      # loaded once, never refreshed
    retrieved: list     # appended to every turn, never cleared

def load_catalog(state: State):
    # Runs at graph start, then sits in the checkpoint forever
    return {"catalog": fetch_all_products()}

builder = StateGraph(State)
builder.add_node("load_catalog", load_catalog)
builder.add_edge(START, "load_catalog")

# MemorySaver persists catalog + retrieved into every checkpoint
graph = builder.compile(checkpointer=MemorySaver())

Drawing the memory line: checkpointer vs store vs retrieval

LangGraph gives you three distinct persistence primitives, and using the right one for each kind of data is most of the fix.

The checkpointer (`MemorySaver`, `SqliteSaver`, `PostgresSaver`) persists thread-scoped state. This is short-term memory: the messages and working variables for one conversation, keyed by `thread_id`. It's the right home for chat history and per-turn scratch space. It is the wrong home for anything authoritative.

The store (`BaseStore`, surfaced as `get_store()` inside a node) persists cross-thread, long-term memory. This is where user preferences and learned facts live, namespaced so they survive across conversations. Useful, but still data the agent itself writes and owns.

Retrieval is the third primitive, and it isn't really a LangGraph object at all: it's a tool call the agent makes when it needs content it doesn't carry. This is the category teams skip, because it's easier to preload into state. But it's the correct home for everything authoritative: the product catalog, published articles, pricing, policy documents, brand voice. That content has its own lifecycle, its own editors, and its own source of truth. Snapshotting it into a checkpoint is how you get stale data.

A clean LangGraph agent therefore keeps three things separate. Messages go in the checkpointer. Durable user-specific facts go in the store. Authoritative content is fetched on demand through a tool node and never persisted into state at all. Once you draw that line, the token bloat and the stale-price bugs mostly disappear, because the agent reads fresh content the moment it needs it instead of dragging a snapshot through every checkpoint.

Cross-thread memory in the store, fetched per turn

User preferences belong in the cross-thread store, not in the per-conversation checkpoint.

from langgraph.store.memory import InMemoryStore
from langgraph.graph import StateGraph

store = InMemoryStore()

def remember_preference(state, *, store):
    namespace = ("prefs", state["user_id"])
    store.put(namespace, "tone", {"value": "concise"})
    return {}

def recall_preference(state, *, store):
    namespace = ("prefs", state["user_id"])
    item = store.get(namespace, "tone")
    return {"tone": item.value if item else "neutral"}

# Compile with BOTH a checkpointer (threads) and a store (cross-thread)
# graph = builder.compile(checkpointer=saver, store=store)

Why preloading content into state is a retrieval problem in disguise

When developers preload the catalog into state, they're treating retrieval as a one-time load. The reality is that authoritative content needs three things a checkpoint can't give it: freshness, scope, and structure.

Freshness, because the content changes underneath you. An editor publishes a new price while the agent's conversation is still open. A checkpointed snapshot can't know that. A per-turn fetch can.

Scope, because the agent rarely needs the whole catalog. It needs the three products relevant to this question. Loading everything into state to use a fraction of it is the same mistake as `SELECT *` then filtering in application code. The filter should run where the data lives.

Structure, because most agent queries have a structural component that pure similarity search can't resolve. "Show me in-stock running shoes under one hundred dollars published this quarter" is a filter (`inStock`, price range, category, publish date), not a vector lookup. If you only have embeddings, you retrieve semantically similar documents and then hope the structural constraints happen to hold. They often don't.

This is exactly the gap Sanity Context fills as a tool source for the agent. Instead of preloading, the agent calls a tool that runs a GROQ query: structural predicates inside the filter, with optional semantic ranking layered on top only when the query is genuinely fuzzy. In Sanity's own production data, the heavy majority of agent calls are structured GROQ queries and schema lookups; semantic search is a small slice, and embeddings are off by default. That matches what these queries actually need. The retrieval discipline is hybrid, but hybrid here means structural predicates plus keyword matching plus optional embeddings, not embeddings everywhere.

Wiring Context MCP as a tool node in your graph

The fastest way to give a LangGraph agent access to Sanity content is the Context MCP endpoint. It's a hosted, read-only MCP server, so you don't write or host any retrieval code. You point LangGraph's MCP client at the endpoint and it discovers the available tools: schema reads, GROQ query execution, reference traversal, and optional semantic search across your dataset.

The read-only part is deliberate and worth internalizing for a stateful agent. Through Context MCP the agent can read everything it needs and write nothing. That's the correct posture for content retrieval: the agent shouldn't be mutating your published catalog mid-conversation. Writes in Sanity go through a separate path (Agent Actions), governed and reviewable, not through the MCP read endpoint your graph talks to.

In practice you load the MCP tools with `langchain-mcp-adapters`, then hand them to a `ToolNode` (or a prebuilt ReAct agent) exactly like any other LangGraph tool. The agent decides when to call them. Because the tools are schema-aware, the model gets the field names and types from your actual dataset, so it constructs queries that match your content model instead of guessing field names.

The payoff against the state-leak problem: nothing about the catalog lives in your checkpoint anymore. The `catalog` and `retrieved` channels are gone. State holds messages and working variables, the checkpointer stays small, resumes stay fast, and every content read is current as of the moment the tool fires.

Attach Context MCP tools to a LangGraph ReAct agent

Load Context MCP tools with langchain-mcp-adapters and hand them to a prebuilt ReAct agent.

from langchain_mcp_adapters.client import MultiServerMCPClient
from langgraph.prebuilt import create_react_agent
from langchain.chat_models import init_chat_model

# Context MCP: hosted, read-only MCP endpoint over your Sanity dataset
client = MultiServerMCPClient({
    "sanity": {
        "transport": "streamable_http",
        "url": "https://mcp.sanity.io/mcp",
    }
})

async def build_agent():
    tools = await client.get_tools()  # schema reads, GROQ, references
    model = init_chat_model("anthropic:claude-sonnet-4-20250514")
    # The agent calls the GROQ/schema tools on demand.
    # No catalog in state, no stale snapshot in the checkpoint.
    return create_react_agent(model, tools)

Hybrid retrieval in one GROQ query, when similarity alone fails

For most agent turns, a structural GROQ filter is all you need, and it's all the MCP tool runs. But some queries are genuinely fuzzy: the user describes what they want in prose that doesn't map cleanly to a field. "Something warm for hiking in shoulder season" has no exact keyword. That's the case where you reach for semantic ranking, and the discipline is to combine it with structure rather than replace it.

GROQ does this in a single query. Structural constraints stay where they belong, as predicates inside the `*[ ... ]` filter, so the agent never sees an out-of-stock or unpublished product no matter how semantically similar it is. Then `score()` blends a keyword match with semantic similarity to rank what's left, and `order(_score desc)` returns the best matches first. The query text is passed once; you don't manage embeddings, embedding columns, or a separate vector index.

This is the routing rule worth remembering. Structured content (your catalog, your articles, anything with a schema) goes through GROQ retrieval like this. Unstructured content (PDFs, support exports, scraped websites) goes through Sanity Context Knowledge Bases instead, which turns those messy sources into ordered documents the agent can read. And a high-volume, machine-generated corpus that needs no editorial governance can still live in a dedicated vector DB; not everything belongs in Sanity. The point is to match the retrieval mechanism to the content, not to push every query through embeddings.

â„šī¸

Embeddings are opt-in, not the default

Don't reach for semantic search first. In Sanity Context production data, the heavy majority of agent calls are structured GROQ queries and schema lookups; semantic similarity is a small slice and embeddings are off by default. Most projects shipping on Context MCP never turn them on. Start with structural filters, add `text::semanticSimilarity()` only when a query is genuinely too fuzzy for predicates and keyword matching to resolve.

One GROQ query: structural filter plus optional semantic ranking

Structural predicates inside the filter, semantic and keyword ranking layered on top with score().

*[
  _type == "product" &&
  inStock == true &&
  price < 100
] | score(
  boost(title match text::query($queryText), 2),
  text::semanticSimilarity($queryText)
) | order(_score desc) [0...5] {
  title, price, _score
}

Keeping agent instructions and brand voice governed, not hardcoded

There's a second category of content that quietly ends up in the wrong place: the agent's own instructions. The system prompt, the brand voice rules, the approved-response snippets, the escalation policy. These usually start as a string literal in the graph definition, which means every edit is a code change, a PR, and a redeploy. Worse, there's no review trail and no preview. A copywriter who needs to soften the tone has to file a ticket and wait for an engineer.

This is editorial content, and it has an editorial lifecycle: someone writes it, someone reviews it, it ships on a schedule, and occasionally it gets rolled back. Sanity is built for exactly that. Storing your agent's instructions and brand voice as documents in a Sanity dataset means non-engineers can edit them in the Studio, changes can be staged and reviewed with Content Releases before they go live, and the agent reads the current version through the same Context MCP endpoint it already uses for catalog data.

In that framing, Sanity is the AI Content Operating System, the intelligent backend for teams building AI content operations at scale: the governed source of truth for everything the agent reads, from the catalog it answers questions about to the instructions that shape how it answers. The graph stays in your codebase. The content that drives the graph lives where the people who own it can edit, review, and version it.

What stays out of Sanity is the ephemeral side of state. Per-user chat history and short-lived session data belong in your checkpointer or a fast key-value store like Upstash or Redis, not in a content dataset. The clean split: ephemeral memory in the checkpointer and store, authoritative and editorial content in Sanity Context, fetched per turn through MCP.