Memory & State

Combine Upstash Redis with Sanity Context for AI agent memory

Upstash Redis

Serverless Redis with a per-request HTTP API for storing chat history, rate limits, and session state in edge and serverless AI agents.

Visit Upstash Redis

Your agent works fine in the first message. By message twelve, the context window is full of stale turns, the user's earlier instruction has scrolled off, and your token bill has quietly doubled because you keep replaying the entire conversation. You reach for Upstash Redis to store chat history, and that solves the persistence problem. Then you hit the next one: which state actually belongs in Redis, and which doesn't?

Some of what your agent reads per turn is not session state at all. It is editorial content: system prompts, brand voice rules, approved answers, product facts. That belongs in Sanity Context, 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, with Knowledge Bases as the second surface for unstructured sources like PDFs and support docs.

This article is about drawing that line cleanly. First the Upstash-native part: how to store, trim, and expire per-user chat memory without leaking tokens or sessions. Then the boundary: why ephemeral conversation state stays in Redis while versioned, reviewable editorial state moves out, and how an agent reads both in the same turn.

Storing per-user chat history without replaying the whole transcript

The naive pattern is one Redis key per conversation holding a JSON array of every message. It works until the array grows. Each turn you fetch the array, append, and send the whole thing to the model. Latency creeps up, the JSON serialization gets expensive, and you blow past the context window.

Use a Redis list instead and read only the tail. The Upstash REST client speaks the same commands as `ioredis`, but over HTTP, so it runs in edge and serverless functions where a TCP connection pool would not survive. `lpush` writes the newest turn to the head, `ltrim` caps the list, and `lrange` reads back a bounded window. You never load the full transcript into memory.

The key naming matters more than it looks. Scope every key to a stable user or session identifier so two browser tabs from the same user don't interleave, and so you can delete one user's history on request without scanning. A `chat:{userId}:{sessionId}` shape gives you per-session isolation and a prefix you can target for cleanup.

Append and read a bounded chat window

A capped Redis list keeps only the last 20 turns, so you never replay an unbounded transcript into the model.

import { Redis } from "@upstash/redis";

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

type Turn = { role: "user" | "assistant"; content: string };

export async function appendTurn(
  userId: string,
  sessionId: string,
  turn: Turn,
) {
  const key = `chat:${userId}:${sessionId}`;
  // newest at the head
  await redis.lpush(key, JSON.stringify(turn));
  // keep only the last 20 turns
  await redis.ltrim(key, 0, 19);
}

export async function recentTurns(
  userId: string,
  sessionId: string,
): Promise<Turn[]> {
  const key = `chat:${userId}:${sessionId}`;
  const raw = await redis.lrange(key, 0, 19);
  // lrange returns head-first, reverse for chronological order
  return raw.map((r) => JSON.parse(r) as Turn).reverse();
}

Expiry, rate limits, and the session leak nobody notices

A chat list with no expiry is a memory leak with a slow fuse. Every abandoned conversation sits in Redis forever, and on a serverless plan you pay per command and per stored byte. Set a TTL on the key the first time you write to it, then refresh it on activity so live sessions don't get cut off mid-conversation.

The subtle bug is that `ltrim` does not reset the TTL, and neither does `lpush` on an existing key. So you set `expire` explicitly on every write. A sliding 24-hour window is a reasonable default for chat: active users keep their history, ghosts get reaped.

Rate limiting is the other thing Redis is genuinely good at here, and Upstash ships `@upstash/ratelimit` so you don't hand-roll token buckets. Limiting per user keys, not per IP, stops one user from draining your model budget while letting everyone else through. Put the limiter in front of the model call, not the whole request, so cheap reads stay fast.

⚠️

ltrim does not refresh the TTL

A common production leak: teams set expire once on session creation, then rely on ltrim to manage the list. ltrim caps length but never touches the expiry clock, and lpush on an existing key leaves the TTL untouched too. Call expire explicitly on every write, or active sessions silently die at the original deadline while abandoned ones can linger if the key was recreated.

Sliding TTL plus a per-user rate limit

Refresh the TTL on each turn and gate the model call behind a per-user sliding window.

import { Redis } from "@upstash/redis";
import { Ratelimit } from "@upstash/ratelimit";

const redis = Redis.fromEnv();

const limiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(20, "1 m"),
  prefix: "agent",
});

export async function guardAndExtend(userId: string, sessionId: string) {
  const { success } = await limiter.limit(userId);
  if (!success) {
    throw new Error("Rate limit exceeded");
  }
  // sliding 24h window, refreshed on every allowed turn
  await redis.expire(`chat:${userId}:${sessionId}`, 60 * 60 * 24);
}

Where Redis stops: state that should be reviewed before it ships

Redis is the right home for state that is ephemeral, per-user, and fine to lose: chat turns, rate-limit counters, a draft the user hasn't submitted, a feature flag for one session. None of it needs review. None of it has an owner outside the running process. If it vanishes, the worst case is the user retypes something.

There is another category of state your agent reads every turn that looks similar but behaves nothing like it. The system prompt that defines the agent's persona. The brand voice rules. The list of approved answers to sensitive questions. The product catalog the agent quotes prices from. This state is shared across all users, edited by humans, and wrong answers have consequences. You do not want a marketer editing a system prompt by redeploying code, and you do not want an unreviewed price change going live the instant someone saves it.

Stuffing that into Redis as a JSON blob is the trap. You lose version history, you lose preview, you lose the ability to stage a change and approve it. The moment a non-engineer needs to touch agent behavior, a key-value store is the wrong tool. That content needs the same review workflow your published website gets, because functionally it is published content: it is what your agent says to the public.

Reading editorial state from Sanity Context in the same turn

This is where Sanity Context comes in alongside Upstash, not instead of it. Sanity is the AI Content Operating System, the intelligent backend for companies building AI content operations at scale, and it is where the governed half of your agent's state lives: instructions, brand voice, knowledge bases, approved responses, anything that should be versioned, edited by humans, and previewed before it goes live.

The fastest way to read it is Context MCP, the hosted, read-only MCP endpoint. Your agent loop attaches it as an MCP server and gets schema-aware tools for free: it can run a GROQ query, traverse references, and read your content model without you writing a sync job. Read-only is the point here. The agent can fetch the current approved system prompt, but it cannot rewrite it; edits go through the Studio where a human reviews them.

If you want full control over the exact query, the second path is a thin tool wrapper around a typed GROQ read. Either way the turn assembles two sources: ephemeral session state from Redis and governed editorial content from Sanity Context. The model sees both, but only one of them was reviewed by a person before it shipped.

Two sources, one turn

Session history comes from Redis; the system prompt and approved answers come from a reviewed Sanity document.

import { Redis } from "@upstash/redis";
import { createClient } from "next-sanity";

const redis = Redis.fromEnv();

const sanity = createClient({
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset: "production",
  apiVersion: "2024-01-01",
  useCdn: false,
});

export async function buildContext(userId: string, sessionId: string) {
  // ephemeral: per-user, fine to lose
  const history = await redis.lrange(
    `chat:${userId}:${sessionId}`,
    0,
    19,
  );

  // governed: reviewed, versioned, edited by humans in the Studio
  const config = await sanity.fetch(
    `*[_type == "agentConfig" && active == true][0]{
      systemPrompt,
      brandVoice,
      "approved": approvedAnswers[]{ question, answer }
    }`,
  );

  return { history, config };
}

Shipping a prompt change without a redeploy or a 2am incident

The real payoff of moving editorial state out of Redis and into Sanity Context is the workflow you get around it. When the system prompt is a JSON value in a Redis key, changing it means an engineer ships code or runs a script against production, with no diff, no review, and no rollback short of remembering the old value. When it is a Sanity document, changing it goes through the same review loop as the rest of your content.

Content Releases is the primitive that ships this exact pattern. A content editor stages a new version of the agent's instructions or a batch of approved answers, the change sits in a release that can be previewed against the live agent, and someone with the right role approves it before it goes live. If a prompt change makes the agent behave badly, you roll the release back instead of paging an engineer.

This is also why the unstructured stuff has its own home. When your agent needs to answer from PDFs, help-center articles, or a support database rather than a clean content model, Knowledge Bases turns those messy sources into well-ordered documents the agent can retrieve against. Structured content goes through GROQ; unstructured sources go through Knowledge Bases; ephemeral session state stays in Redis. Three homes, drawn by how the state behaves, not by which one was easiest to reach for first.

💡

A quick test for where state belongs

Ask one question: if this value were wrong in production, who fixes it and how? If the answer is the running process, a retry, or the user retyping, it is ephemeral, keep it in Upstash Redis. If the answer is a human editing it, with a review and a rollback, it is editorial, put it in Sanity Context where Content Releases gives you staging and approval.