Claude Code Context and Memory Internals, Rebuilt as a LangGraph Agent
How Claude Code builds API calls, runs subagents and compacts long sessions, then the same ideas rebuilt in LangGraph with checkpointers and memory files.
This article is published in English.
Details from the leaked Claude Code v2.1.87 source show how the tool handles context and memory over long coding sessions. It deals with the usual long-running agent problems: context that outgrows the model's window, tool output nobody can predict, recalling memory from earlier sessions at the right moment, and keeping project instructions intact after summarization. What follows breaks down the mechanism and then rebuilds a simplified version in LangGraph with its built-in checkpointers.
How Claude Code manages context
An API request is assembled from three parts. Two of them, the system prompt (identity plus git context) and the tools array (JSON schemas for Bash, Read, Edit, Skill, Agent and MCP), stay outside the message list and compaction never reaches them. The rest goes into the messages array as user-role entries wrapped in <system-reminder> tags: instruction files at the very top, the running conversation and tool results in the middle, and attachment entries (memories, skill and agent listings, MCP instructions, hook output) added after every round of tool execution. Once the window fills, compaction condenses every message and regenerates the vital attachments from their original sources. Query loop, subagents and compaction come next.
The query loop
Typing a message starts a loop in which each iteration is a single API call, and it continues until the model answers without any tool calls.
Before the loop begins, a background Sonnet call picks up to five relevant memory files using only the text of the user's question, because sending the whole conversation would cost too much. The results aren't used right away. They land after tool execution, so the first API call of a turn never includes them.
Each iteration trims the in-memory messages array before it goes to the API. It first cuts the list down to whatever follows the last compaction boundary, and the complete JSONL file on disk isn't modified. It then writes any oversized tool result to its own file and substitutes a 2 kb preview inline. If the token count still sits above about 93% of the context window after that, compaction fires.
A reply is either plain text, which finishes the turn, or one or more tool calls, which execute and send the loop around again. The tools fall into groups: general ones such as Bash, Read, Edit and Grep; the Skill tool, which pulls a complete SKILL.md into the conversation; the Agent tool, which launches an isolated subagent that returns a single result; and ToolSearch, which fetches a deferred tool schema for the following iteration. Once each tool round ends, delta attachments are injected for skill listings, agent listings, MCP instructions and any memory prefetch that has finished, and only material that changed since the previous pass is emitted.
Subagents
Calling the Agent tool makes Claude Code create an isolated context in the same process. The subagent starts with a copy of the parent's conversation, receives its own tools and system prompt, runs with thinking disabled to lower output token cost, and executes the very same query-loop code on its own. Explore and Plan agents also lose CLAUDE.md and git status from that copy, which saves further tokens.
Only the final result message flows back to the parent. Tool calls, reasoning and any compaction inside the subagent are written to a sidechain transcript, subagents/agent-{id}.jsonl, which the parent never opens. Cost stays isolated that way: dozens of internal tool calls can add up to a single message in the parent's context.
Compaction
A microcompact layer exists in the code to shrink old tool results before a full compaction, but it's switched off and out of scope here. Compaction itself offers two strategies, with the second serving as a fallback when the first isn't available. Strategy one substitutes a prepared session-memory file immediately, so compaction needs no API call of its own; a background agent assembled that file step by step in earlier turns through separate calls, and the path is presently disabled by feature gates. Strategy two, the one in use, is a complete LLM compact. A forked agent gets the present messages and a structured compact prompt and boils the conversation down to around five thousand tokens. The newest messages are kept verbatim next to that summary so the model still has its immediate working context, and everything older is swapped for the summary.
Next, whatever attachments sat in messages[] and were summarized away get rebuilt. Agent listings, deferred tool names and MCP instructions are announced in full once more, and session start hooks run again. Skill content that was invoked comes back from an in-memory map limited to about 25,000 tokens, and the five most recently touched files are read from disk anew. The system prompt, tools[], Claude.md and git context never lived in messages[], so compaction can't affect them; their memoized caches are simply cleared so the next turn sees fresh values.
One thing is left out on purpose: the skill catalogue isn't rebuilt, saving roughly 4,000 tokens. The side effect is that after compaction the model has no way to find skills it hasn't yet used, unless Claude.md lists them. Compaction also loses detail, since the earlier summary is summarized together with all else, and by the third pass the original facts have been squeezed three times. Nothing is truly gone, because the append-only JSONL on disk holds the entire history and the compact boundary marker (parentUuid: null) shows the loader where to begin on resume.
A simplified LangGraph version
A smaller version of the same architecture can be built with LangGraph. It already provides checkpointers for persistence and RemoveMessage for deleting messages. Cross-session memory follows the same approach as Claude Code, with markdown files on disk read every turn. The compaction logic is the piece that has to be written.
In the mapping diagram, each arrow connects a Claude Code concept on the left with its LangGraph equivalent on the right. The edge marked improvement points out that LangGraph is able to keep instructions outside messages[], so compaction never touches them.
Five nodes make up the graph. The dotted edges out of call_model are conditional, sending tool calls back through tools and text replies on to post_turn. In load_context, the memory files (.agent/memory/*.md) are read off disk, then folded into the SystemMessage in call_model. That keeps them out of messages[] altogether, so they come through compaction untouched.
Graph construction
The nodes run in sequence, and after call_model a conditional branch either cycles through tools or leaves for post_turn. A SQLite checkpointer holds all state across process restarts.
def build_graph():
builder = StateGraph(AgentState)
builder.add_node("load_context", load_context)
builder.add_node("prune_and_compact", prune_and_compact)
builder.add_node("call_model", call_model)
builder.add_node("tools", ToolNode(TOOLS))
builder.add_node("post_turn", post_turn)
builder.add_edge(START, "load_context")
builder.add_edge("load_context", "prune_and_compact")
builder.add_edge("prune_and_compact", "call_model")
builder.add_conditional_edges(
"call_model", route_after_model,
{"tools": "tools", "post_turn": "post_turn"},
)
builder.add_edge("tools", "call_model")
builder.add_edge("post_turn", END)
checkpointer = SqliteSaver(sqlite3.connect("checkpoints.sqlite"))
return builder.compile(checkpointer=checkpointer)
# Subagent: separate StateGraph with own call_model + tools loop
subagent_builder = StateGraph(SubagentState)
subagent_builder.add_node("call_model", subagent_call_model)
subagent_builder.add_node("tools", ToolNode([bash, read_file, write_file]))
subagent_builder.add_edge(START, "call_model")
subagent_builder.add_conditional_edges("call_model", subagent_route,
{"tools": "tools", "__end__": END})
subagent_builder.add_edge("tools", "call_model")
subagent_graph = subagent_builder.compile()
The link between the two graphs sits inside the run_subagent tool:
@tool
def run_subagent(task: str) -> str:
"""Run an isolated subagent for a specific task."""
result = subagent_graph.invoke({"messages": [HumanMessage(content=task)]})
return result["messages"][-1].content # only final result
Once the model calls run_subagent, the main tools node executes the function and that invokes the subagent graph, which runs its own call_model and tools loop separately. Only the final answer comes back, as a string, so the main graph sees an ordinary tool result. The subagent has no instructions, memory files or checkpointer, so its context is light and each call stands alone. The sample has one subagent type. Claude Code picks among several at runtime (Explore for search, Plan for planning, a general-purpose type with every tool, and plugin agents like vercel:ai-architect), each with its own tools, permissions and system prompt.
Prompts
These are simplified versions used in the LangGraph replication, and Claude Code's real prompts are more detailed.
Claude Code's own compact prompt is much bigger, with nine sections, an <analysis> scratchpad, a "CRITICAL: Respond with TEXT ONLY" preamble and a sample format block. The cut-down version below retains the essentials:
Your task is to create a detailed summary of the conversation so far,
paying close attention to the user's explicit requests and your previous actions.
This summary should capture technical details, code patterns, and decisions
that would be essential for continuing the work without losing context.
Before writing the summary, analyze the conversation chronologically in <analysis> tags.
Then provide the summary in <summary> tags with these sections:
1. Primary Request and Intent: The user's explicit requests in detail.
2. Key Technical Concepts: Important technologies, frameworks, and patterns discussed.
3. Files and Code Sections: Specific files examined, modified, or created, with paths
and why each was important.
4. Errors and Fixes: Errors encountered, how they were fixed, and any user corrections
or feedback (pay special attention to "don't do X" or "actually I meant Y").
5. Problem Solving: Problems solved and any ongoing troubleshooting.
6. Pending Tasks: Tasks explicitly requested but not yet completed.
7. Current Work: What was being worked on immediately before this summary,
with specific file names and details from the most recent messages.
In code, the conversation is split first, with trim_messages identifying the recent tail to keep. Only the older part goes to the model with this prompt, and that model sees all of it (user messages, assistant replies, tool results) and condenses it. The complete implementation is in the compaction section further down.
The session memory update prompt in Claude Code uses a ten-section template with token caps per section, {{variableName}} substitution and strict rules about preserving section headers. A simplified version captures the core idea:
Based on the conversation above, update the session notes.
Update each section below with specific, actionable details from the conversation.
Write info-dense content: file paths, function names, error messages, exact commands.
Skip a section if there are no new insights to add. Do not add filler.
Keep each section under 500 words. If approaching the limit, cycle out less important
details while preserving the most critical information.
Always update Current State to reflect the most recent work.
Sections:
# Session Title
# Current State
# Task Specification
# Files and Functions
# Errors and Corrections
# Key Results
# Next Steps
The production template adds sections for Workflow, Codebase Documentation, Learnings, Key Results and Worklog, limits each section to 2,000 tokens and the whole file to 12,000, and has the background agent edit sections in place with FileEdit and not rewrite the file.
State: instructions outside messages
On each API call, instructions and memories are loaded from disk and placed in a SystemMessage, as prependUserContext() does in Claude Code. They aren't kept in messages[], which is why compaction ignores them. Invoked skill content and session memory live in state fields that survive compaction:
class AgentState(MessagesState):
summary: str # compaction summary
instructions: str # CLAUDE.md equivalent (never compacted)
invoked_skills: dict[str, str] # skill name → content (survives compaction)
session_memory: str | None # background summary for instant compaction
# Cross-session memory: .agent/memory/*.md files read from disk every turn.
# Survives compaction (re-read from disk, never in messages[]).
# Agent creates memories via write_file tool. For production at scale,
# use a lightweight LLM call to select relevant files (Claude Code's approach).
Compaction: summarize old, keep recent
The design follows Claude Code. Recent messages stay intact, since the model was just working with them, and only older ones are summarized.
def compact(state: AgentState) -> dict:
messages = state["messages"]
# Keep recent messages (Claude Code uses calculateMessagesToKeepIndex)
kept = trim_messages(
messages, strategy="last",
token_counter=count_tokens_approximately,
max_tokens=COMPACT_THRESHOLD // 5,
start_on="human", end_on=("human", "tool"),
)
kept_ids = {id(m) for m in kept}
# Summarise only the OLDER messages
to_summarise = [m for m in messages if id(m) not in kept_ids]
if not to_summarise:
return {}
response = model.invoke(
to_summarise + [HumanMessage(content=COMPACT_PROMPT)]
)
# Delete old, keep recent, inject summary + re-inject skills
delete = [RemoveMessage(id=m.id) for m in to_summarise]
summary = HumanMessage(content=f"[Summary]\n{response.content}")
skill_msgs = [
HumanMessage(content=f"[Skill: {n}]\n{c[:5000]}")
for n, c in state.get("invoked_skills", {}).items()
]
return {"messages": [*delete, summary, *skill_msgs], "summary": response.content}
The outcome is the summary, then the kept recent messages, then the re-injected skills, and not a lone summary replacing everything. Calling RemoveMessage produces a fresh checkpoint minus the old messages, while earlier checkpoints stay in the database for recovery, just as Claude Code's JSONL preserves pre-compact entries.
Memory: file-based, read every turn
The example copies the way Claude Code really does it. Memory files sit on disk under .agent/memory/*.md, call_model reads them each turn, and they never enter messages[]. Since they're re-read from disk and not rebuilt from the conversation, compaction can't lose them.
# In call_model — read memory files from disk every turn
MEMORY_DIR = Path(".agent/memory")
if MEMORY_DIR.exists():
for mem_file in MEMORY_DIR.glob("*.md"):
content = mem_file.read_text()[:4096]
system_parts.append(f"Memory ({mem_file.stem}):\n{content}")
# Agent creates memories by writing files:
# write_file(".agent/memory/user_prefs.md", "User prefers concise responses.")
Claude Code spends a separate Sonnet call per turn to select the relevant memory files, up to 5 out of potentially many: a fast model receives the question and a manifest of file names and descriptions and returns the best matches. The example loads every file, which is easier but won't scale. Production setups with many memories should follow the original: a lightweight LLM call with the question and the memory index decides what to load.
Skills: a tool with progressive disclosure
@tool
def load_skill(skill_name: str) -> str:
"""Load a specialised skill prompt by name."""
return SKILL_REGISTRY.get(skill_name, f"Unknown skill: {skill_name}")
List critical skills in the instruction files so they survive compaction. The skill catalogue is a convenience and not a requirement.
What gets stored after a session
Take a session where the user asks several questions, triggers a compaction, then returns later. The agent's stored data looks like this:
.agent-data/
checkpoints.sqlite ~5 MB (all state snapshots, grows with session)
tool-results/
bash-84ffc64a.txt 15 KB (large bash output persisted with preview)
.agent/
memory/
project_goal.md 206 B (cross-session, survives compaction)
user_prefs.md 125 B (read from disk every turn)
For the same work, Claude Code stores more, because it uses an append-only JSONL transcript that is never deleted and keeps separate files for each subagent and compact backup:
~/.claude/projects/{cwd}/
{sessionId}.jsonl 22 MB (full transcript, never deleted)
{sessionId}/
tool-results/ 12 files
subagents/ 96 files (subagent transcripts + compact backups)
session-memory/ (disabled)
history.jsonl 13k lines (prompt recall, UI only)
memory/ 4 files (auto-memory)
LangGraph's checkpointer keeps state snapshots in place of an event log, so the footprint is smaller. Earlier checkpoints remain and can be examined through graph.get_state_history(config) when debugging or recovering.
What the replica covers
The LangGraph replica handles the heart of the job: loading instructions from disk, compacting older messages while recent ones stay, saving big tool results to files, and re-reading cross-session memory each turn. The production system adds LLM-driven memory selection through a quick Sonnet call, delta attachments covering skills and agents, isolated subagents, hooks from plugins, and handling of the prompt cache. With the design laid out like this, it's a handy reference for anyone building their own agent.