Memory & State

Combine Xata with Sanity Context for Postgres-native AI agent state

Xata

Serverless Postgres with branching, full-text search, and a typed TypeScript SDK for storing agent state, memory, and structured records.

Visit Xata

Your agent works in the demo. Then a user resumes a three-day-old conversation, your loop reads the wrong row from the messages table, and the model confidently answers based on stale state. You reach for Xata because it gives you serverless Postgres with a typed SDK, branching, and full-text search without provisioning a database, and it is genuinely good at holding the per-user, per-session state your agent accumulates. The trouble starts when you ask that same table to also serve as the source of truth for the content the agent reasons about: product specs, policy docs, approved responses, brand voice. Now you are hand-syncing a CMS into Postgres rows and watching them drift.

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. A second surface, Knowledge Bases, handles unstructured sources like PDFs, websites, and support data. It is not a database for chat history, and it does not replace Xata.

This article draws the line. Xata holds the mutable, ephemeral, per-user state your agent writes on every turn. Sanity Context holds the governed, editor-owned content your agent reads. We will wire both into one loop, show where each one wins, and keep your retrieval honest.

The state your agent actually accumulates

Start with what an agent loop writes during a single session, because this is the part Xata is built for. Every turn you append a message, you update a running summary, you stash tool-call results, and you bump a last-active timestamp. None of that is content. It is state: mutable, high-write, scoped to one user or one thread, and worthless to anyone outside this conversation.

Xata gives you a typed table for exactly this. You define a schema in the dashboard or in code, and the SDK generates types so a misspelled column fails at compile time instead of at 2am. The branching model means you can spin a branch off main to test a schema migration against real-shaped data without touching production, which matters when you decide three weeks in that `messages` needs a `tokens` column.

The common mistake is treating every write as equal. A chat turn and a brand-voice guideline are not the same kind of data. One changes hundreds of times an hour and nobody reviews it; the other changes monthly and a human absolutely should review it. Conflating them is what later forces the painful sync job. Keep the per-session, per-user, write-heavy records in Xata and let it do what it is good at.

A typed Xata table for agent session state

State writes that happen every turn belong in Xata.

import { getXataClient } from "./xata";

const xata = getXataClient();

// Append a turn to the running session.
await xata.db.messages.create({
  sessionId: session.id,
  userId: user.id,
  role: "assistant",
  content: reply,
  tokens: usage.totalTokens,
  createdAt: new Date(),
});

// Read the last N turns to rebuild context for the next call.
const history = await xata.db.messages
  .filter({ sessionId: session.id })
  .sort("createdAt", "desc")
  .getMany({ pagination: { size: 20 } });

Summaries, recall, and the token-window problem

The next thing that breaks is the context window. By turn forty your message history no longer fits, so you start summarizing older turns into a compact running summary and storing it back. This works, but naive recall (just paste the last twenty messages) loses the detail a user expects you to remember from turn three.

Xata's full-text search helps here. Because Xata indexes your columns, you can search the user's own message history for the turn relevant to the current question instead of replaying everything. That is keyword recall over per-user state, and it is the right tool: the corpus is small, scoped to one user, and changes constantly. You do not want a heavyweight vector pipeline for this; you want a fast `search` call against rows that already live in Postgres.

The pattern that holds up in production is layered. Keep the last few turns verbatim for immediacy. Keep a rolling summary for the gist. Use Xata search to pull back the specific older turn when the current question references it. All three are state operations, all three stay in Xata, and none of them touch the content your agent reasons about. That separation is the thing that keeps your retrieval debuggable later, because when an answer is wrong you can ask a precise question: was it bad state, or bad content?

Keyword recall over a user's own history

Scoped full-text search recalls the relevant turn without flooding the window.

import { getXataClient } from "./xata";

const xata = getXataClient();

// Pull the older turn that actually relates to the current question,
// instead of replaying the whole transcript into the prompt.
const recalled = await xata.db.messages.search(userQuestion, {
  filter: { userId: user.id },
  target: ["content"],
  page: { size: 3 },
});

const recalledText = recalled.records
  .map((r) => `${r.role}: ${r.content}`)
  .join("\n");

Where the Postgres-everything plan breaks

Here is the failure mode that sends teams looking for a second tool. Your agent needs to answer questions about real content: the current return policy, this quarter's pricing, the approved phrasing for a sensitive topic. The instinct is to add a `documents` table in Xata and sync your CMS into it on a cron.

That sync is where the bugs live. The CMS is the source of truth, but the agent reads the Postgres copy, so every edit has a window where they disagree. An editor fixes a policy at 9am; your nightly sync means the agent quotes the old policy until midnight. Worse, the structure flattens: a CMS models a product with references to variants, a brand, and localized copy, but your `documents` table stores a denormalized blob, so the agent loses the ability to traverse those relationships and answers about a variant that was discontinued.

You also inherit a governance gap. Xata has no concept of editorial review, draft versus published, or scheduled releases, because it was never meant to. So the content an agent serves to customers has no approval workflow, and the people who own that content, the support leads and product marketers, cannot see or touch it. The right move is not a bigger sync job. It is to stop copying content into your state store at all, and read it from the system that already governs it.

⚠️

The cron-sync trap

A nightly (or even hourly) CMS-to-Postgres sync guarantees a window where your agent answers from stale content. The fix is not a faster sync. It is reading governed content live at query time, so an editor's correction reaches the agent on the next call instead of the next sync.

Reading governed content with Context MCP

This is where Sanity Context enters, and it enters as the read side of your loop, not as a replacement for Xata. Context MCP is a hosted, read-only MCP endpoint over your Sanity dataset. Your agent loop attaches it as an MCP server and gets schema-aware tools out of the box: it can read your content types, run GROQ queries, and traverse references without you writing a sync job or hand-rolling tool definitions.

The read-only constraint is the point, not a limitation. The agent can query the current return policy, the live price, or the approved response, and it always reads what editors have published right now, because there is no copy to fall out of date. Writes never go through MCP; they go through Agent Actions, so a runaway agent cannot mutate your content store. Editors keep ownership of the content in Sanity Studio, with drafts, review, and scheduled Content Releases, and the agent reads the published result.

Most projects start and stay here, on structured retrieval. The heavy majority of agent calls are GROQ queries and schema lookups against well-modeled content, not semantic search. Semantic similarity is opt-in and off by default, and a large share of teams never turn it on because their content is structured enough that predicates and references answer the question. Sanity is the Content Operating System for the AI era, the intelligent backend that keeps the content an agent serves governed, reviewable, and current; Xata stays the system of record for the state the agent writes.

Attach Context MCP alongside your Xata-backed state

MCP gives the agent schema-aware read tools without a sync job.

import { experimental_createMCPClient } from "ai";

// Read-only, hosted MCP endpoint over your Sanity dataset.
const sanityContext = await experimental_createMCPClient({
  transport: {
    type: "sse",
    url: "https://mcp.sanity.io/mcp",
  },
});

const contentTools = await sanityContext.tools();

// The agent now has schema-aware read tools (GROQ, schema reads,
// reference traversal) in addition to your Xata state writes.
const result = await generateText({
  model,
  tools: { ...contentTools },
  prompt: userQuestion,
});

Drawing the line: state in Xata, content in Sanity

With both wired in, the routing rule is simple and worth making explicit in your code, because future-you will forget it. If the data is written by the agent or the user and scoped to a session, it is state, and it goes to Xata. If the data is authored by a human, reviewed before it ships, and shared across all users, it is content, and the agent reads it from Sanity Context.

A single turn now touches both cleanly. The agent reads recent turns and recall from Xata to know who it is talking to and what was said. It queries Context MCP to know what is true right now about your products and policies. It generates a reply, then writes only the new turn back to Xata. Nothing about the content was copied; nothing about the state leaked into your CMS.

The payoff shows up when something goes wrong. Because the two stores are cleanly separated, a wrong answer has a clear suspect list. If the agent forgot the user's earlier request, that is a state bug, and you inspect the Xata rows. If the agent quoted an old policy, that is a content bug, and you check what GROQ returned and what the editor published. You are no longer debugging a denormalized sync job that smeared both failure modes together.

One turn, two stores, clean boundaries

Xata owns the writes; Sanity Context owns the governed reads.

// 1. STATE: read who and what (Xata)
const history = await xata.db.messages
  .filter({ sessionId })
  .sort("createdAt", "desc")
  .getMany({ pagination: { size: 20 } });

// 2. CONTENT: read what's true now (Sanity Context via MCP tools)
const result = await generateText({
  model,
  tools: { ...contentTools },
  messages: [...toMessages(history), { role: "user", content: userQuestion }],
});

// 3. STATE: write only the new turn back (Xata)
await xata.db.messages.create({
  sessionId,
  userId,
  role: "assistant",
  content: result.text,
  createdAt: new Date(),
});

When content is unstructured, and when a vector DB still wins

Two edge cases keep your architecture honest. First, not all the content an agent needs is well-modeled. If the answer lives in a stack of PDFs, a support knowledge base, or scraped help-center pages, GROQ over a clean schema is the wrong tool because there is no clean schema yet. That is what Sanity Context Knowledge Bases is for: it turns messy sources into well-ordered documents with a clear table of contents that the agent can retrieve against. Reach for Knowledge Bases when the corpus is unstructured; reach for GROQ when it is structured.

Second, do not overcorrect and try to put everything in Sanity. If you are storing millions of machine-generated embeddings that no human will ever review, edit, or approve, a dedicated vector database like the one you might already pair with Xata still earns its place. The test is governance: does an editor need to own this content? If yes, it belongs in Sanity Context. If it is a high-volume, machine-owned corpus, keep it in the vector store.

When you do enable semantic search inside Sanity, it is a GROQ feature, not a separate pipeline. The retrieval combines structural predicates, keyword matching, and optional semantic similarity in one query and orders by a computed score, so a question with a structural component (a date range, a product variant, a publication state) that pure embeddings cannot resolve still returns the right document. That hybrid discipline, structured first and semantic only where it earns its keep, is why structured retrieval stays the common case and your agent stops guessing.

💡

Route by ownership, not by format

Ask one question of every piece of data: does a human need to review it before an agent serves it? Reviewed content goes to Sanity Context (GROQ for structured, Knowledge Bases for unstructured). Unreviewed, machine-owned, high-volume data stays in your vector store or in Xata. Ownership, not file type, is the cleanest routing rule.