Memory & State

Combine Supabase with Sanity Context for AI agents: chat history vs knowledge

Supabase

Postgres-based backend with auth, realtime, pgvector, and row-level security, the natural home for per-user chat history and ephemeral agent state.

Visit Supabase

Your agent works in the demo. Then a user comes back three days later, asks a follow-up, and the agent has no idea what they discussed. You add a `messages` table in Supabase, key it by `session_id`, and now history persists. Good. Then someone asks the agent a question about your product catalog, it confidently cites a price that changed last week, and you realize you've been stuffing a hand-maintained JSON blob into the system prompt and forgetting to update it. Two different problems, and they keep getting tangled.

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 exports. The point of naming it here: it is not where you put chat history.

This article draws the line. Supabase is where ephemeral, per-user, high-write state lives: conversation turns, session metadata, draft scratchpads. Sanity Context is where governed, versioned, human-edited knowledge lives: the catalog, the brand voice, the approved answers. We will build the Supabase side first, then show exactly where the handoff happens.

Storing chat history in Supabase the right way

The naive version is one table: `messages(id, session_id, role, content, created_at)`. That works until you need to load a conversation and it returns 400 rows of tool-call noise. Two changes pay off early. First, separate the durable transcript from the working context the model actually sees per turn. You rarely want to replay every token; you want a recent window plus a summary. Second, store role-tagged content as `jsonb` so you can keep tool calls and structured parts without flattening them to strings.

The write path is a plain insert on every turn. The read path is the one that bites people. Loading the full history and shipping it to the model blows your context window and your token bill around turn thirty. Load a bounded window instead, ordered newest-first, then reverse it client-side so the model reads in chronological order. Keep a separate `summary` column on the session row that you update every N turns with a compressed recap, so the model has continuity without the raw transcript.

This is genuinely Supabase's strength. Postgres gives you transactions, `created_at` indexes, and foreign keys to your auth users for free. Row-level security means a session belongs to a user and nobody else can read it, enforced at the database, not in your application code where you will eventually forget a check.

Append a turn and load a bounded window

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_ANON_KEY!
)

// write: one insert per turn
async function appendTurn(sessionId: string, role: string, content: unknown) {
  const { error } = await supabase
    .from('messages')
    .insert({ session_id: sessionId, role, content })
  if (error) throw error
}

// read: bounded window, newest-first, then reverse for the model
async function loadWindow(sessionId: string, limit = 20) {
  const { data, error } = await supabase
    .from('messages')
    .select('role, content, created_at')
    .eq('session_id', sessionId)
    .order('created_at', { ascending: false })
    .limit(limit)
  if (error) throw error
  return (data ?? []).reverse()
}

Locking sessions down with row-level security

If your agent serves more than one user, the most common production incident is not a hallucination. It is user A loading user B's conversation because a query forgot a `where user_id = ...` clause. Application-level filtering is one missed line away from a data leak. Row-level security moves that guarantee into Postgres, where it holds no matter which code path hits the table.

The pattern: enable RLS on the table, then write a policy that compares the row's `user_id` to `auth.uid()`, the authenticated user from the Supabase JWT. Now a `select *` with no filter returns only that user's rows, automatically. The same policy covers inserts, so a user cannot write a message into someone else's session even if your handler is sloppy.

This matters for agents specifically because tool-calling loops generate a lot of writes from a lot of code paths, and not all of them are written by you. The moment you let an agent insert rows, you want the database to be the thing that says no. RLS is also why ephemeral chat history is a poor fit for content systems built around editorial publishing: those systems govern who can edit and approve a document, not who owns a millisecond-fresh row keyed to a session. Different access model, different tool.

RLS policy scoping sessions to the authenticated user

alter table messages enable row level security;

-- a user can only read their own session rows
create policy "read own messages"
  on messages for select
  using (
    session_id in (
      select id from sessions where user_id = auth.uid()
    )
  );

-- and can only insert into their own sessions
create policy "write own messages"
  on messages for insert
  with check (
    session_id in (
      select id from sessions where user_id = auth.uid()
    )
  );

Where pgvector helps, and where it quietly fails

Supabase ships pgvector, so the obvious next step is to embed your knowledge, drop it in a `documents` table, and do similarity search at query time. For genuinely unstructured text this is reasonable. For your structured content it produces a frustrating failure: the user asks for "the spring jacket under $120 that's still in stock," and pure vector similarity hands back a discontinued jacket because the embedding captured "spring jacket" and ignored the price, the stock state, and the publication status.

The reason is structural. Embeddings encode topical similarity. They do not encode predicates. A date range, an author, a product variant, a `status == 'published'` flag: these are exact constraints, and cosine distance has no way to enforce an exact constraint. You end up bolting a metadata filter onto the vector query, then discovering the metadata in your `documents` table has drifted from the source of truth because nobody re-embedded after the last price change.

This is the crack where a hand-maintained embedding store starts costing more than it saves. Every edit in your real content system now needs a sync job to re-chunk, re-embed, and upsert into Postgres. Miss one, and the agent cites stale data with full confidence. The vector table is downstream of a source it does not own.

âš ī¸

Vector search is one ingredient, not the whole meal

Vector similarity and good retrieval are not the same thing. When a query carries a structural component (a date range, a price ceiling, a stock flag, a publication state), embeddings alone return plausible-but-wrong documents, because cosine distance cannot enforce an exact predicate. The fix is hybrid: structural filters plus keyword match plus optional semantic ranking, not embeddings everywhere.

Drawing the line: ephemeral state in Supabase, governed knowledge in Sanity Context

Here is the split that holds up in production. Supabase owns state that is per-user, high-write, ephemeral, and meaningless to a human editor: conversation turns, session summaries, draft scratchpads, rate-limit counters, feature flags keyed to a user. Nobody reviews a chat message before it is written. It belongs in Postgres with RLS.

Sanity Context owns knowledge that is shared, versioned, human-edited, and reviewed before it goes live: the product catalog, the brand voice guidelines, the approved answers to common questions, the documentation the agent quotes. This is the structured-content tool source your agent calls when it needs a fact about your business, rather than a fact about this user's session.

The practical reason to keep these apart is that they have opposite governance needs. Chat history should never be edited after the fact; that would be falsifying a record. Knowledge should always be editable, with an audit trail and an approval step, because a wrong price or an off-brand answer is a content bug a human needs to fix and ship deliberately. Sanity is the AI Content Operating System, the intelligent backend for teams running AI content operations at scale, and Content Releases gives you exactly that workflow: stage a batch of catalog edits, preview what the agent will retrieve, and publish atomically. Trying to bolt that onto a `documents` table in Postgres means reinventing versioning, review, and preview by hand.

💡

A simple test for which store a piece of data belongs in

Ask: would a human ever review or approve this before it goes live? If no (a chat turn, a session counter), it is ephemeral state, so Supabase. If yes (a price, an approved answer, brand voice), it is governed knowledge, so Sanity Context. The review question sorts almost everything.

Wiring Sanity Context into the agent loop via Context MCP

The fastest way to give your agent structured knowledge is the Context MCP endpoint. It is a hosted, read-only MCP server, so your agent loop attaches it as an MCP server and gets schema-aware tools out of the box: it can read your schema, run GROQ queries, and traverse references without you writing a single retrieval function. Read-only is deliberate. The agent can ask what the catalog says; it cannot mutate the catalog through MCP. Writes go through a separate path (Agent Actions), so an agent loop can never silently corrupt your content.

In an MCP-aware framework, you point the client at the endpoint and the tools appear. Your Supabase chat history still loads exactly as before; nothing about the state side changes. The two stores stay cleanly separated: one connection for per-user history, one MCP connection for governed knowledge.

The key difference from the pgvector approach is that there is no sync job. The agent queries the live dataset through MCP, so when an editor changes a price in the Studio and publishes, the next query reflects it. You are not maintaining a downstream copy that drifts. For teams that want full control over the exact query rather than the default tools, a thin custom `tool()` wrapper that runs a typed GROQ query is the second path, but MCP is where you start.

Attach Context MCP alongside your Supabase-backed history

import { experimental_createMCPClient } from 'ai'

// read-only, schema-aware tools from your Sanity dataset
const sanityContext = await experimental_createMCPClient({
  transport: {
    type: 'sse',
    url: 'https://mcp.sanity.io/<projectId>/<dataset>',
  },
})

const contextTools = await sanityContext.tools()

// chat history still comes from Supabase, unchanged
const history = await loadWindow(sessionId)

const result = await generateText({
  model,
  messages: history,
  // governed knowledge tools; agent reads, never writes, via MCP
  tools: contextTools,
})

Structured retrieval first, semantic search only when it earns its place

There is a temptation, once you have a content source, to make every retrieval semantic. Resist it. In practice the heavy majority of useful agent retrieval is structured: a GROQ query that filters by exact predicates and reads a handful of fields. "Show me published jackets under $120 in stock" is a filter, not a similarity search, and a filter returns the right answer deterministically every time. Semantic search is a smaller slice, reached for when the user's phrasing does not map to a clean predicate.

When you do need both, the discipline is hybrid inside a single GROQ query: structural predicates narrow the candidate set, then keyword match and optional semantic similarity rank what survives. Embeddings in Sanity Context are opt-in and off by default; most projects shipping on Context MCP never turn them on, because structured retrieval already answers the question. The hybrid form combines a `text::query()` keyword match with `text::semanticSimilarity()`, scored together and ordered by `_score`.

For genuinely unstructured corpora, support exports, PDFs, scraped web pages, the right surface is not a GROQ query at all but Sanity Context Knowledge Bases, which turns messy sources into well-ordered documents with a clear table of contents. And for high-volume, machine-generated data that no human will ever edit, a dedicated vector store in Supabase still has a place. Not everything belongs in a governed content system. The judgment is matching the store to the data's governance needs, not forcing one tool to do everything.

â„šī¸

The typical case is structured, not semantic

Most agent retrieval against a content dataset is exact-predicate GROQ filtering, not vector search. Embeddings are opt-in and off by default on Context MCP, and many production projects never enable them. Reach for semantic similarity when a query genuinely resists clean predicates, not as the default.