●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Building a Persistent Memory Agent with Claude API, pgvector, and Redis: A Complete
A complete guide to building production-ready persistent memory for Claude API agents using PostgreSQL + pgvector + Redis. Learn vector search, layered memory architecture, session management, and GDPR-compliant data handling.
If you've built chatbots or agents with the Claude API, you've likely run into this frustration: users have to re-explain themselves every session. Preferences, past decisions, ongoing projects—gone the moment a conversation ends.
The Claude API is stateless by design. Each request stands alone, and any prior context must be explicitly included in the messages array. This makes the API clean and composable, but it means you are responsible for building a memory system if you want your agent to feel intelligent over time.
This guide walks you through building a production-grade persistent memory system using:
PostgreSQL + pgvector — long-term memory with semantic vector search
Redis — short-term session memory with fast in-memory access
Claude API — reasoning engine that autonomously invokes memory tools
By the end, you'll have an agent that remembers user preferences, references past conversations, and continuously improves its responses the more it interacts with each person.
Architecture Overview: A Three-Layer Memory System
Human memory isn't monolithic. We have working memory that lasts seconds, episodic memory that captures experiences, and long-term semantic memory that persists for years. Your AI agent's memory can follow the same layered model.
Layer 1 — Short-Term Memory (Redis)
Stores the current session's message history. Expires via TTL (time to live) once the session is inactive, keeping Redis lean and cost-efficient. Redis is the right tool here because access speed is the priority—agents frequently look back at the last few messages.
Stores important information extracted from conversations: user preferences, key facts, learned skills, notable events. Each memory is embedded as a vector, so retrieval uses cosine similarity rather than exact keyword matching. This means queries like "does this user like concise answers?" return the right memories even if those exact words were never used.
Layer 3 — Episodic Memory (PostgreSQL)
Stores session summaries with timestamps. This enables recalling "what we discussed last month" at a coarser granularity, without storing every individual message forever.
Together, these three layers balance speed, precision, and storage cost for a realistic production system.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦The measured recall vs. p95 latency trade-off when tuning HNSW ef_search from 40 to 100
✦Cutting embedding API calls by roughly 60% with a Redis cache keyed on the query hash
✦Importance-score decay and 0.95-similarity dedup patterns that keep the memory table from bloating in production
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
-- init.sqlCREATE EXTENSION IF NOT EXISTS vector;-- Long-term memory tableCREATE TABLE long_term_memories ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id TEXT NOT NULL, content TEXT NOT NULL, content_embedding vector(1536), -- dimensions for text-embedding-3-small memory_type TEXT NOT NULL, -- 'fact', 'preference', 'skill', 'event' importance_score FLOAT DEFAULT 0.5, access_count INTEGER DEFAULT 0, last_accessed_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ -- NULL = permanent);-- HNSW index for approximate nearest neighbor searchCREATE INDEX ON long_term_memories USING hnsw (content_embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);-- Index for per-user retrievalCREATE INDEX ON long_term_memories (user_id, created_at DESC);-- Episode memory table (session summaries)CREATE TABLE episode_memories ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id TEXT NOT NULL, session_id TEXT NOT NULL, summary TEXT NOT NULL, key_topics TEXT[], sentiment TEXT, created_at TIMESTAMPTZ DEFAULT NOW());CREATE INDEX ON episode_memories (user_id, created_at DESC);
Run docker compose up -d and verify both services are healthy before proceeding.
Long-Term Memory: Storing and Searching with pgvector
The LongTermMemory class handles embedding generation, vector insertion, and similarity search. We use OpenAI's text-embedding-3-small for embeddings since Anthropic does not currently offer a public embeddings API.
The key design choice here is updating access_count on every search hit. Memories that get retrieved often organically rise in relevance, approximating how human memory strengthens with repeated recall.
Short-Term Memory: Redis for Session Context
Session memory lives in Redis as a capped list. Each session automatically expires after 24 hours of inactivity, keeping storage lean and handling session cleanup without a cron job.
Redis lists give O(1) push and tail-range retrieval, making them ideal for conversation queues. The 50-message cap is a sensible default—you can tune it based on your typical conversation length and token budget constraints.
Integrating with Claude API: Memory as Tools
Here's where it comes together. Claude's tool use allows the model to autonomously decide when to search memory and when to save new information. We expose two tools: search_memory and save_memory.
// src/agent/MemoryAgent.tsimport Anthropic from "@anthropic-ai/sdk";import { LongTermMemory } from "../memory/LongTermMemory";import { ShortTermMemory } from "../memory/ShortTermMemory";const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });export class MemoryAgent { private longTerm: LongTermMemory; private shortTerm: ShortTermMemory; constructor() { this.longTerm = new LongTermMemory(); this.shortTerm = new ShortTermMemory(); } private tools: Anthropic.Tool[] = [ { name: "search_memory", description: "Search the user's stored memories and past conversation history. " + "Always call this at the start of a conversation to retrieve relevant context.", input_schema: { type: "object" as const, properties: { query: { type: "string", description: "Natural language search query" }, limit: { type: "number", description: "Max memories to return (default: 3)" }, }, required: ["query"], }, }, { name: "save_memory", description: "Save important information to the user's long-term memory. " + "Use this for preferences, facts, skills, and notable events.", input_schema: { type: "object" as const, properties: { content: { type: "string", description: "The information to remember" }, memory_type: { type: "string", enum: ["fact", "preference", "skill", "event"], }, importance: { type: "number", description: "Importance score between 0.0 and 1.0", }, }, required: ["content", "memory_type"], }, }, ]; async chat( userId: string, sessionId: string, userMessage: string ): Promise<string> { await this.shortTerm.push(userId, sessionId, { role: "user", content: userMessage, }); const history = await this.shortTerm.getHistory(userId, sessionId, 10); const messages: Anthropic.MessageParam[] = history.map((h) => ({ role: h.role, content: h.content, })); while (true) { const response = await client.messages.create({ model: "claude-opus-4-6", max_tokens: 2048, system: `You are an AI assistant with persistent memory.At the start of each conversation, use search_memory to retrieve relevant context.When you learn something important about the user, use save_memory proactively.This allows you to build a deeper, more personalized relationship over time.`, messages, tools: this.tools, }); if (response.stop_reason === "end_turn") { const text = response.content .filter((b) => b.type === "text") .map((b) => (b as Anthropic.TextBlock).text) .join(""); await this.shortTerm.push(userId, sessionId, { role: "assistant", content: text, }); return text; } if (response.stop_reason === "tool_use") { const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: response.content, }; messages.push(assistantMessage); const toolResults: Anthropic.ToolResultBlockParam[] = []; for (const block of response.content) { if (block.type !== "tool_use") continue; let result = ""; if (block.name === "search_memory") { const input = block.input as { query: string; limit?: number }; const memories = await this.longTerm.search( userId, input.query, input.limit ?? 3 ); result = memories.length > 0 ? memories .map( (m) => `[${m.memoryType}] ${m.content} (score: ${m.importanceScore})` ) .join("\n") : "No relevant memories found."; } if (block.name === "save_memory") { const input = block.input as { content: string; memory_type: "fact" | "preference" | "skill" | "event"; importance?: number; }; const id = await this.longTerm.save( userId, input.content, input.memory_type, input.importance ?? 0.5 ); result = `Memory saved (ID: ${id})`; } toolResults.push({ type: "tool_result", tool_use_id: block.id, content: result, }); } messages.push({ role: "user", content: toolResults }); } } }}
The agent loop continues until Claude returns end_turn, processing tool calls in between. This pattern is identical to any Claude API tool use implementation—the memory system is just specialized tooling layered on top.
Session Summarization: Building Episodic Memory
When a session ends, summarize the conversation and store it as an episode. This captures the "gist" of what happened without retaining every individual message forever.
async endSession(userId: string, sessionId: string): Promise<void> { const history = await this.shortTerm.endSession(userId, sessionId); if (history.length < 3) return; // Skip trivially short sessions const conversationText = history .map((h) => `${h.role}: ${h.content}`) .join("\n"); // Use Haiku for cost-efficient summarization const summaryResponse = await client.messages.create({ model: "claude-haiku-4-5-20251001", max_tokens: 512, messages: [ { role: "user", content: `Summarize the following conversation in 2-3 sentences.Focus on the main topics discussed and any important decisions or preferences expressed.${conversationText}`, }, ], }); const summary = summaryResponse.content[0].type === "text" ? summaryResponse.content[0].text : ""; await this.longTerm["pool"].query( `INSERT INTO episode_memories (user_id, session_id, summary) VALUES ($1, $2, $3)`, [userId, sessionId, summary] );}
Routing the summarization task to claude-haiku-4-5-20251001 rather than Opus keeps this operation cost-effective. Model routing—choosing the right model for each subtask—is one of the highest-leverage cost optimizations in production AI systems. For a deeper look, see our guide on Claude API cost optimization production patterns.
Common Errors and Fixes
Vector type cast error in pg
The pg Node.js driver doesn't know how to serialize JavaScript arrays as PostgreSQL vector types. Always convert embeddings to a JSON string and cast explicitly:
// Wrong — will throw a type errorawait pool.query("INSERT INTO t VALUES ($1)", [embeddingArray]);// Correct — serialize and castawait pool.query("INSERT INTO t VALUES ($1::vector)", [ JSON.stringify(embeddingArray),]);
Redis ECONNREFUSED before connect() is awaited
createClient() returns a disconnected client. Call await client.connect() before issuing any commands. In production, add reconnect logic and error listeners to handle transient network failures:
If you filter by user_id before the vector comparison, PostgreSQL may fall back to a sequential scan. Verify with EXPLAIN ANALYZE. To force index usage during testing, run SET enable_seqscan = off;. In production, ensure your WHERE clauses are index-friendly and consider partitioning by user_id for very large datasets.
Privacy and Data Compliance
Any system that stores personal memory must provide users with the ability to view and delete their data. This is not optional in GDPR jurisdictions.
// Complete user data deletion (right to erasure)async deleteUserData(userId: string): Promise<void> { await this.longTerm.deleteAll(userId); // Clears both memory tables const keys = await this.shortTerm.client.keys(`session:${userId}:*`); if (keys.length > 0) { await this.shortTerm.client.del(keys); }}
Beyond deletion, consider:
Displaying stored memories to users in your UI (transparency)
Getting explicit consent before saving preferences
Setting expires_at on sensitive memories so they auto-purge
Encrypting content columns at rest for regulated industries
Once your memory system is handling real users, a few performance bottlenecks tend to emerge. Here's how to address each one proactively.
Embedding generation latency
Every search_memory and save_memory call hits the embedding API, adding 50–200ms of latency per invocation. In a tool-use loop where Claude calls search_memory two or three times before responding, this compounds quickly. Two mitigations help most:
First, batch similar searches when possible. If Claude is likely to search for multiple related topics, structure the system prompt to encourage a single descriptive query rather than multiple narrow ones. Second, cache embeddings for frequently repeated queries using Redis with a TTL of a few hours. The cache key can be an MD5 hash of the query string.
The default HNSW parameters (m = 16, ef_construction = 64) balance build speed and recall. For production systems where search accuracy is more important than index build time, increase ef_construction to 128. For faster searches at the cost of a small recall drop, reduce ef_search:
-- Increase search quality at query timeSET hnsw.ef_search = 100;-- Or set globally in postgresql.conf-- hnsw.ef_search = 100
Run pg_prewarm('long_term_memories_content_embedding_idx') after index creation to load the index into shared memory and avoid cold-start latency on the first queries.
Memory pruning for long-term users
Users who interact with your agent daily for months will accumulate thousands of memories. Without pruning, search quality degrades as irrelevant old memories compete with current ones. Implement a scheduled job that removes low-importance, rarely-accessed memories:
-- Delete memories that score below threshold and haven't been accessed in 90 daysDELETE FROM long_term_memoriesWHERE importance_score < 0.3 AND access_count < 2 AND last_accessed_at < NOW() - INTERVAL '90 days' AND expires_at IS NULL;
Run this as a daily cron or a scheduled Cloudflare Worker. This keeps the active memory store lean and the vector index focused on information that actually matters to each user.
Partitioning for multi-tenant systems
If you're building a product serving thousands of users, consider partitioning long_term_memories by user_id hash range. PostgreSQL declarative partitioning keeps each user's memories in a separate physical segment, dramatically improving query plans for per-user searches:
CREATE TABLE long_term_memories ( id UUID NOT NULL, user_id TEXT NOT NULL, -- other columns...) PARTITION BY HASH (user_id);CREATE TABLE long_term_memories_p0 PARTITION OF long_term_memories FOR VALUES WITH (MODULUS 4, REMAINDER 0);-- Repeat for p1, p2, p3
Each partition gets its own HNSW index, keeping index sizes manageable even as your total memory count grows into the tens of millions.
Memory Quality: Teaching Claude What's Worth Remembering
Raw tool use gives Claude the ability to save anything. Left unconstrained, agents tend to over-save—creating noise that degrades search quality over time. The system prompt plays a major role in shaping memory quality.
A prompt that produces high signal-to-noise memories:
You have access to a persistent memory system. Use it thoughtfully:
SAVE memories when:
- The user explicitly states a preference ("I prefer concise answers")
- You learn a durable fact ("user works as a data engineer at a fintech company")
- A significant event occurs ("user successfully deployed their first ML model today")
- The user teaches you something about themselves that will affect future interactions
DO NOT save:
- Transient emotional states ("user seems frustrated right now")
- Information that's already captured in the current session history
- Generic facts about the world that aren't specific to this user
When searching memory, use a single descriptive query that captures the full context
of what you're trying to recall, rather than multiple narrow queries.
This level of prompt engineering for memory quality is underappreciated but makes a significant difference in practice. Users who interact with a well-tuned memory agent notice it feeling "smarter" over time, while a poorly tuned one starts repeating itself or saving irrelevant details.
Monitoring and Observability
A production memory system needs metrics. At minimum, track:
Memory save rate: How many memories are saved per session? Sudden spikes may indicate the agent is over-saving.
Search hit rate: What percentage of searches return at least one result above the similarity threshold? Low hit rates may mean your threshold is too high or your memory corpus is too sparse.
Embedding API latency and errors: The embedding call is often the most fragile part of the pipeline. Set up alerting if p95 latency exceeds 500ms.
Memory count per user: Track growth to catch users accumulating unexpectedly large memory sets.
Export these metrics to your observability stack (Datadog, Grafana, or a simple CloudWatch dashboard) and set baseline alerts before your first production users arrive.
A field note from production
As an indie developer, I wired this exact stack into the wallpaper app and relaxation apps I run on the App Store and Google Play, so the assistant could remember each person's taste across sessions. The metric that mattered most in practice was the search hit rate: once it climbed past roughly 70%, returning users started describing the app as if it "remembered" them—which is exactly the feeling this architecture exists to create.
Comparing Memory Approaches: When to Use What
Before committing to this architecture, it's worth understanding the trade-offs against simpler alternatives.
Option A: Full conversation history in context
The simplest approach is to include the entire conversation history in every API request. Claude's 1M-token context window makes this feasible for many use cases. The downside is token cost: at scale, sending 50,000 tokens of conversation history per request adds up quickly. This approach also degrades gracefully—there's no external state to manage, no database failures to handle.
Option B: MCP-based memory servers
The Claude long-term memory MCP implementation approach uses MCP servers as the memory interface. This is excellent when you're running Claude Code or other MCP-aware clients, but adds infrastructure complexity for API-only applications.
Semantic search that scales beyond keyword matching
Fine-grained control over what gets remembered and why
Multi-user SaaS products where each customer has their own memory space
GDPR and privacy compliance controls at the database level
The operational complexity is higher than options A or B, but the control and scalability you gain justify it for production applications serving real users.
Decision framework:
Prototype or internal tool → Option A (full history in context)
Claude Code power user → Option B (MCP server)
Production SaaS with real users → Option C (this guide)
Summary
In this guide, you built a complete three-layer persistent memory system for Claude API agents:
Redis handles short-term session context with automatic TTL expiry
PostgreSQL + pgvector stores long-term memories with cosine similarity search
Episode summaries capture the arc of past conversations at a coarser granularity
Claude tool use lets the model autonomously search and save memories
This architecture gives your agent the ability to build genuine, personalized relationships with users over time—turning a stateless API into something that feels like it truly knows them.
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.