The default Claude agent forgets everything the moment the conversation ends. That's fine for one-shot tasks. It's a problem the moment you need an agent that learns over time, picks up where it left off, or maintains state across multiple sessions.
Persistent memory isn't a single feature — it's a design decision you make by choosing what kind of memory fits the task. Get it wrong and the agent either overwhelms its context window with irrelevant history, or loses information it needs and asks the same questions repeatedly. Get it right and the agent feels coherent across sessions in a way that one-shot calls never will.
This guide covers the four memory patterns that actually work in practice, how to implement each with Claude, and which to reach for first.
Quick summary
- Memory in agents is just information passed back into context — the mechanism varies, the principle doesn't.
- In-context memory is the simplest: append to the messages array. Runs out fast for long tasks.
- File-based memory (flat files or structured JSON) is cheap, durable, and easier to debug than databases.
- Vector storage is right for semantic retrieval — "what did we know about X?" — not for structured state.
- Key-value stores work best for structured state that the agent explicitly reads and writes.
- For most agents, start with file-based memory and graduate to vector search only when semantic retrieval is the actual bottleneck.
What memory means for an agent
Memory in an agent is any mechanism that makes past information available in a future prompt. That's it. The sophistication lies in how you select which past information is relevant now — because you can't stuff everything into context.
There are four patterns worth knowing. They're not mutually exclusive — a production agent often combines two or three.
In-context memory: the floor
The simplest approach is just appending to the messages array you pass on each API call. Every tool result, every intermediate reasoning step, every prior exchange — it all stays in the messages list.
const messages: Anthropic.MessageParam[] = [];
// First turn
messages.push({ role: "user", content: "Analyze the API surface of our auth module." });
const response1 = await callClaude(messages);
messages.push({ role: "assistant", content: response1.content });
// Second turn — Claude remembers the first analysis
messages.push({ role: "user", content: "Now identify which functions are untested." });
const response2 = await callClaude(messages);
messages.push({ role: "assistant", content: response2.content });
This works until it doesn't. Claude's context window is large but not infinite. For a task that takes 30 tool calls over an hour, you'll fill it. The fix is to summarize older turns periodically:
async function maybeSummarize(
messages: Anthropic.MessageParam[],
threshold = 30
) {
if (messages.length < threshold) return messages;
// Keep recent messages, summarize the rest
const toSummarize = messages.slice(0, -10);
const recent = messages.slice(-10);
const summary = await client.messages.create({
model: "claude-haiku-4-5-20251001", // faster, cheaper for summarization
max_tokens: 1000,
messages: [
...toSummarize,
{
role: "user",
content:
"Summarize the key findings, decisions, and current state from this conversation in 3-5 bullet points. Be specific — include file names, function names, and concrete details.",
},
],
});
const summaryText =
summary.content.find((b) => b.type === "text")?.text ?? "";
return [
{
role: "user" as const,
content: `Previous session summary:\n${summaryText}`,
},
{ role: "assistant" as const, content: "Understood. Continuing from that context." },
...recent,
];
}
Summarization compresses context but loses detail. For most multi-turn tasks within a single session, in-context memory is enough. The jump to external memory is only necessary when sessions span multiple invocations.
File-based memory: the workhorse
For agents that run across sessions — a coding agent that works on a codebase over multiple days, or an assistant that accumulates knowledge about a project — file-based memory is the practical starting point.
The idea is simple: before each session, load relevant memory from disk into the system prompt. During the session, write new information to disk when the task completes.
import fs from "fs/promises";
import path from "path";
const MEMORY_DIR = ".agent-memory";
async function loadMemory(key: string): Promise<string> {
const filePath = path.join(MEMORY_DIR, `${key}.md`);
try {
return await fs.readFile(filePath, "utf-8");
} catch {
return ""; // No memory yet
}
}
async function saveMemory(key: string, content: string): Promise<void> {
await fs.mkdir(MEMORY_DIR, { recursive: true });
const filePath = path.join(MEMORY_DIR, `${key}.md`);
await fs.writeFile(filePath, content, "utf-8");
}
async function runWithMemory(task: string, memoryKey: string) {
const existingMemory = await loadMemory(memoryKey);
const systemPrompt = existingMemory
? `You are a coding agent working on this project.\n\nKnown context from previous sessions:\n${existingMemory}`
: "You are a coding agent working on this project.";
// ... run the agent loop
// After completion, ask Claude to update the memory
const updatedMemory = await client.messages.create({
model: "claude-haiku-4-5-20251001",
max_tokens: 500,
messages: [
{
role: "user",
content: `Here is what was known before:\n${existingMemory}\n\nThe agent just completed this task: ${task}\n\nUpdate the memory to reflect any new findings, file changes, or decisions made. Keep it under 400 words. Use bullet points.`,
},
],
});
const newMemory =
updatedMemory.content.find((b) => b.type === "text")?.text ?? existingMemory;
await saveMemory(memoryKey, newMemory);
}
This is what I use for longer agentic workflows in my own projects. The memory file is human-readable, easy to inspect, and you can manually correct it when the agent writes something wrong. No database setup, no embedding costs.
The limitation is retrieval: you load the whole memory file into context every session. When the file grows beyond a few thousand words, you're burning context on information that might not be relevant. That's when vector search becomes useful.
Vector memory: for semantic retrieval
Vector storage (Pinecone, Weaviate, Supabase pgvector, or just a local FAISS index) lets you retrieve memory by semantic similarity rather than loading everything. Instead of "give me all memory," you ask "what do we know about authentication?"
The setup has more moving parts:
import { Anthropic } from "@anthropic-ai/sdk";
// Assume a vector client like @pinecone-database/pinecone
async function embedText(text: string): Promise<number[]> {
// Use any embedding model — text-embedding-3-small, Voyage, etc.
const response = await fetch("https://api.openai.com/v1/embeddings", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ input: text, model: "text-embedding-3-small" }),
});
const data = await response.json();
return data.data[0].embedding;
}
async function storeMemory(id: string, text: string, metadata: Record<string, string>) {
const embedding = await embedText(text);
await vectorIndex.upsert([{ id, values: embedding, metadata: { text, ...metadata } }]);
}
async function retrieveMemory(query: string, topK = 5): Promise<string[]> {
const queryEmbedding = await embedText(query);
const results = await vectorIndex.query({ vector: queryEmbedding, topK, includeMetadata: true });
return results.matches.map((m) => m.metadata?.text as string).filter(Boolean);
}
Then in the agent loop, before each call to Claude, retrieve relevant memories and inject them:
async function buildSystemPrompt(currentTask: string): Promise<string> {
const relevantMemories = await retrieveMemory(currentTask, 5);
const memoryBlock = relevantMemories.length
? `Relevant context from memory:\n${relevantMemories.map((m, i) => `${i + 1}. ${m}`).join("\n")}`
: "";
return `You are a coding agent.\n\n${memoryBlock}`;
}
Vector memory is worth the extra setup when you have a large knowledge base (hundreds of past sessions, a big codebase summary, user preferences accumulated over months) and you can't afford to load it all. For most agents I've built, file-based memory was enough and easier to debug. Vector search is the right tool when you've actually hit the retrieval problem.
Key-value memory: for structured state
Some agents need to maintain structured state — a to-do list the agent updates as it works, a set of known facts, user preferences, a registry of completed tasks. Markdown files don't compose well for this. Key-value stores do.
The simplest version is a JSON file:
async function readState<T>(key: string): Promise<T | null> {
const filePath = path.join(MEMORY_DIR, "state.json");
try {
const raw = await fs.readFile(filePath, "utf-8");
const state = JSON.parse(raw);
return state[key] ?? null;
} catch {
return null;
}
}
async function writeState<T>(key: string, value: T): Promise<void> {
const filePath = path.join(MEMORY_DIR, "state.json");
let state: Record<string, unknown> = {};
try {
const raw = await fs.readFile(filePath, "utf-8");
state = JSON.parse(raw);
} catch {
// Fresh state
}
state[key] = value;
await fs.writeFile(filePath, JSON.stringify(state, null, 2));
}
Expose readState and writeState as tools the agent can call directly:
{
name: "read_agent_state",
description: "Read a value from persistent agent state by key. Use this to retrieve information saved in previous sessions — completed tasks, known preferences, or structured data.",
input_schema: {
type: "object",
properties: { key: { type: "string", description: "The state key to retrieve." } },
required: ["key"],
},
}
This gives Claude explicit control over its own memory. It decides what to remember and what to retrieve. Useful for task-management agents, personal assistants, and any scenario where the agent needs to actively manage its own state rather than have it injected by your orchestration code.
Combining memory types
Real agents often use two or three types together. Here's how I'd structure a coding agent that works across sessions:
| Memory type | What it stores | When it's loaded |
|---|---|---|
| In-context | Current session history | Every turn |
| File-based | Project summary, key decisions | Session start (system prompt) |
| Key-value | Task list, completed items | On agent request (via tool) |
| Vector | Past code reviews, error log | Semantic query (via tool) |
The project summary gives Claude orientation at the start. The task list keeps it from re-doing completed work. The vector index answers specific questions like "have we seen this bug before?" without blowing the context budget.
Start simpler than you think you need. In-context memory handles most tasks. File-based covers most multi-session cases. Only add vector and key-value storage when you have a concrete problem that file-based can't solve.
What breaks memory in practice
Trusting Claude to summarize accurately. Claude is good at summarization but will occasionally drop critical details. For anything that matters — file paths, function names, error codes — have your code extract and store those explicitly rather than relying on a generated summary.
Growing the memory file without pruning. A memory file that grows by 200 words per session is 7,000 words after a month of daily use. Set a max size and trigger re-summarization when you hit it, or prune stale entries (tasks completed more than 30 days ago, for example).
Not giving Claude a way to correct wrong memories. If the agent writes something incorrect to memory and you don't provide a way to update or delete entries, it will carry that wrong belief forward. Expose a delete_state tool alongside write_state.
Storing raw tool outputs instead of summaries. A 50KB JSON response from an API is useful in context once. In memory, you want what Claude learned from it, not the raw response.
[INTERNAL-LINK: agentic loop fundamentals → building your first AI agent with agentic loops] [INTERNAL-LINK: agentic coding → using Claude to write, edit and execute code autonomously]
