How to Build a Self-Evolving Claude Code Memory System With Obsidian and Claude Code Hooks
Use Claude Code hooks to automatically capture session logs, extract lessons, and build a wiki that grows smarter with every conversation.
The Problem With AI Agents That Forget Everything
Every time you start a new Claude Code session, you’re starting from zero. You explain the same architectural decisions you made last week. You re-describe the same quirks in your codebase. You watch Claude make the same category of mistake you corrected three sessions ago.
Claude Code is powerful — but it has no memory between sessions by default. Each conversation is a clean slate. That’s fine for one-off tasks, but it’s a real friction point for developers using Claude Code as a daily coding partner.
The good news is that Claude Code hooks give you a clean way to fix this. You can set up a system that automatically captures what happens in each session, extracts reusable lessons, and writes them into an Obsidian vault that grows smarter over time. This article walks through how to build that system from scratch.
What Claude Code Hooks Actually Are
Claude Code hooks are shell commands or scripts that run at specific points in Claude Code’s lifecycle. They’re defined in a settings file and fire automatically — no manual triggering required.
There are five hook types:
- PreToolUse — runs before Claude uses any tool
- PostToolUse — runs after a tool call completes
- Notification — fires when Claude sends a notification
- Stop — fires when Claude finishes its response and stops
- SubagentStop — fires when a subagent task ends
For a memory system, the Stop hook is the most useful. It fires at the end of every session, making it a natural place to trigger a post-session analysis script.
Hooks receive a JSON payload via stdin with context about the session — including the session ID, project directory, and tool call history. Your script reads that payload, does whatever processing you want, and exits. Claude Code moves on.
Where Hooks Live
Hooks are configured in settings.json. There are two scopes:
- User-level:
~/.claude/settings.json— applies across all projects - Project-level:
.claude/settings.jsoninside your repo — applies only to that project
For a memory system that works everywhere, start with the user-level config.
Architecture: How the System Works
Before writing any code, it helps to understand what we’re building and why each component exists.
The flow looks like this:
- You finish a Claude Code session (conversation ends)
- The
Stophook fires and calls a Python script - The script reads the session transcript from Claude Code’s local storage
- It sends the transcript to Claude (via the Anthropic API) with a prompt asking for lessons, patterns, and decisions
- Claude returns structured insights
- The script writes those insights as markdown files into your Obsidian vault
- On future sessions, Claude Code reads from the vault as context
Over time, the vault accumulates real knowledge from your actual work — not generic coding advice, but patterns and decisions specific to your projects.
What Gets Captured
The system extracts four categories of information:
- Patterns: Reusable code approaches that worked well
- Mistakes: Errors made and how they were corrected
- Decisions: Architectural choices with reasoning
- Context: Project-specific terminology, constraints, or quirks
Each category maps to a subfolder in the Obsidian vault. An auto-updated index file ties everything together.
Step 1: Set Up Your Obsidian Vault Structure
Start by creating a dedicated vault (or a subfolder in your existing vault) for Claude Code memory. The structure should be consistent so your scripts always know where to write.
~/claude-memory/
├── Patterns/
├── Mistakes/
├── Decisions/
├── Context/
├── Sessions/
└── Index.md
If you’re adding this to an existing Obsidian vault, create a ClaudeMemory/ parent folder and mirror this structure inside it.
Open Obsidian and point it at ~/claude-memory/ (or your chosen path). You don’t need any special plugins to start — the system writes plain markdown that Obsidian renders natively.
Optional: Install the Templater Plugin
Templater is a community Obsidian plugin that makes auto-generated notes look cleaner. It’s not required, but if you already use it, you can wire it into the output templates later.
Step 2: Configure the Claude Code Hook
Open (or create) ~/.claude/settings.json and add the Stop hook:
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 ~/.claude/hooks/memory_extractor.py"
}
]
}
]
}
}
The matcher field filters which tool calls trigger the hook — leaving it empty means it fires on every session stop, which is what we want.
Now create the hooks directory:
mkdir -p ~/.claude/hooks
touch ~/.claude/hooks/memory_extractor.py
chmod +x ~/.claude/hooks/memory_extractor.py
Step 3: Write the Memory Extractor Script
This is the core of the system. The script reads stdin (the JSON payload Claude Code passes), locates the session transcript, calls Claude to extract insights, and writes the results to your Obsidian vault.
Here’s a working implementation:
#!/usr/bin/env python3
import json
import sys
import os
import re
from datetime import datetime
from pathlib import Path
import anthropic
# --- Configuration ---
VAULT_PATH = Path.home() / "claude-memory"
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects"
# --- Read hook input ---
try:
hook_input = json.loads(sys.stdin.read())
except json.JSONDecodeError:
sys.exit(0)
session_id = hook_input.get("session_id", "")
project_dir = hook_input.get("cwd", "")
if not session_id:
sys.exit(0)
# --- Locate session transcript ---
def find_session_transcript(session_id: str) -> str:
"""Search all project folders for a matching session JSONL file."""
for project_folder in CLAUDE_PROJECTS_DIR.iterdir():
if not project_folder.is_dir():
continue
for jsonl_file in project_folder.glob("*.jsonl"):
if session_id in jsonl_file.stem:
return jsonl_file.read_text(encoding="utf-8")
return ""
raw_transcript = find_session_transcript(session_id)
if not raw_transcript:
sys.exit(0)
# --- Parse transcript into readable format ---
def parse_transcript(raw: str) -> str:
lines = []
for line in raw.strip().splitlines():
try:
entry = json.loads(line)
role = entry.get("role", "")
content = entry.get("content", "")
if isinstance(content, list):
text_parts = [
c.get("text", "") for c in content if c.get("type") == "text"
]
content = " ".join(text_parts)
if role and content:
lines.append(f"{role.upper()}: {content[:2000]}")
except json.JSONDecodeError:
continue
return "\n\n".join(lines)
transcript_text = parse_transcript(raw_transcript)
if len(transcript_text) < 100:
sys.exit(0)
# --- Call Claude to extract insights ---
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
extraction_prompt = f"""You are analyzing a Claude Code session transcript to extract reusable knowledge.
TRANSCRIPT:
{transcript_text[:12000]}
Extract structured insights in this exact JSON format:
{{
"patterns": [
{{"title": "short title", "description": "what the pattern is", "code_snippet": "optional example", "tags": ["tag1"]}}
],
"mistakes": [
{{"title": "short title", "error": "what went wrong", "fix": "how it was resolved", "tags": ["tag1"]}}
],
"decisions": [
{{"title": "short title", "decision": "what was decided", "reasoning": "why", "tags": ["tag1"]}}
],
"context": [
{{"title": "short title", "detail": "project-specific info", "tags": ["tag1"]}}
],
"session_summary": "2-3 sentence summary of what this session accomplished"
}}
Only include items that are genuinely reusable or important. Return valid JSON only."""
try:
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=4096,
messages=[{"role": "user", "content": extraction_prompt}]
)
insights_raw = response.content[0].text
except Exception as e:
sys.exit(0)
# Strip markdown code fences if present
insights_raw = re.sub(r"```(?:json)?\n?", "", insights_raw).strip()
try:
insights = json.loads(insights_raw)
except json.JSONDecodeError:
sys.exit(0)
# --- Write to Obsidian vault ---
date_str = datetime.now().strftime("%Y-%m-%d")
time_str = datetime.now().strftime("%H%M")
project_name = Path(project_dir).name or "unknown"
def write_note(folder: str, title: str, content: str, tags: list) -> None:
safe_title = re.sub(r'[^\w\s-]', '', title).strip().replace(' ', '-')
filepath = VAULT_PATH / folder / f"{date_str}-{safe_title}.md"
tag_str = " ".join(f"#{t}" for t in tags) if tags else ""
frontmatter = f"""---
date: {date_str}
project: {project_name}
session: {session_id[:8]}
tags: [{", ".join(tags)}]
---
"""
VAULT_PATH.mkdir(parents=True, exist_ok=True)
(VAULT_PATH / folder).mkdir(exist_ok=True)
filepath.write_text(frontmatter + content + f"\n\n{tag_str}", encoding="utf-8")
# Write patterns
for item in insights.get("patterns", []):
snippet = f"\n\n```\n{item.get('code_snippet', '')}\n```" if item.get("code_snippet") else ""
write_note(
"Patterns",
item["title"],
f"## {item['title']}\n\n{item['description']}{snippet}",
item.get("tags", []) + ["pattern"]
)
# Write mistakes
for item in insights.get("mistakes", []):
write_note(
"Mistakes",
item["title"],
f"## {item['title']}\n\n**Error:** {item['error']}\n\n**Fix:** {item['fix']}",
item.get("tags", []) + ["mistake"]
)
# Write decisions
for item in insights.get("decisions", []):
write_note(
"Decisions",
item["title"],
f"## {item['title']}\n\n**Decision:** {item['decision']}\n\n**Reasoning:** {item['reasoning']}",
item.get("tags", []) + ["decision"]
)
# Write context
for item in insights.get("context", []):
write_note(
"Context",
item["title"],
f"## {item['title']}\n\n{item['detail']}",
item.get("tags", []) + ["context"]
)
# Write session log
session_note_path = VAULT_PATH / "Sessions" / f"{date_str}-{time_str}-{project_name}.md"
(VAULT_PATH / "Sessions").mkdir(exist_ok=True)
session_note_path.write_text(
f"---\ndate: {date_str}\nproject: {project_name}\nsession: {session_id[:8]}\n---\n\n"
f"## Session Summary\n\n{insights.get('session_summary', 'No summary generated.')}\n",
encoding="utf-8"
)
# Regenerate index
update_index()
The update_index() function scans the vault and writes a fresh Index.md. Add this before the main script body:
def update_index() -> None:
sections = {}
for folder in ["Patterns", "Mistakes", "Decisions", "Context"]:
folder_path = VAULT_PATH / folder
if folder_path.exists():
files = sorted(folder_path.glob("*.md"), reverse=True)
sections[folder] = [f"- [[{f.stem}]]" for f in files[:20]]
index_content = f"# Claude Code Memory Index\n\n*Last updated: {datetime.now().strftime('%Y-%m-%d %H:%M')}*\n\n"
for section, links in sections.items():
index_content += f"## {section}\n\n" + "\n".join(links) + "\n\n"
(VAULT_PATH / "Index.md").write_text(index_content, encoding="utf-8")
Step 4: Feed Memory Back Into Claude Code
Writing to the vault is half the system. The other half is making sure Claude Code actually reads from it at the start of each session.
Create a CLAUDE.md file in your project root. This file is automatically read by Claude Code when it starts. Add a section that pulls in relevant memory:
## Project Memory
Refer to ~/claude-memory/Index.md for accumulated patterns and decisions from previous sessions.
Key context files for this project:
- ~/claude-memory/Context/ — project-specific notes
- ~/claude-memory/Decisions/ — past architectural choices
- ~/claude-memory/Mistakes/ — known pitfalls to avoid
Claude Code will read CLAUDE.md at session start and use it as context throughout the conversation. For larger vaults, you can also instruct Claude to search specific files when it encounters a relevant problem.
Using a PreToolUse Hook for Targeted Recall
If you want even tighter integration, add a PreToolUse hook that injects specific memory notes when Claude is about to write code:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "python3 ~/.claude/hooks/recall_patterns.py"
}
]
}
]
}
}
This hook fires before any file-write tool, giving your recall script a chance to surface relevant patterns from the vault before code gets written.
Step 5: Tune the Extraction Quality
The extraction quality depends heavily on your prompt. A few adjustments that make a real difference:
Be specific about what “reusable” means. Tell Claude in the extraction prompt that you only want lessons that would apply to future sessions — not one-time fixes or project-specific trivia.
Set a minimum quality bar. Add a line like: “Only include a pattern if it’s something a developer might forget or get wrong again. Skip anything obvious.”
Include the project type in the prompt. Prepend PROJECT TYPE: {project_name} to the extraction prompt so Claude can calibrate what counts as relevant context.
Keep transcripts focused. If sessions run very long (20,000+ tokens), consider truncating to the last 8,000 tokens or extracting only the assistant turns, which typically contain the most actionable content.
Step 6: Maintain and Prune the Vault
A self-evolving system that never prunes will eventually become noisy. Build a simple maintenance habit into the setup:
- Weekly review: Spend 5 minutes in Obsidian scanning the
Mistakes/folder. Delete or archive notes that no longer apply. - Tag consolidation: If you notice dozens of notes with the same tag, consider merging them into a single longer reference note.
- Session log rotation: Archive sessions older than 90 days into a
Sessions/Archive/subfolder.
You can automate parts of this by adding a second script triggered weekly via cron. Have it use Claude to identify duplicate or stale notes and flag them for review rather than deleting automatically — keep a human in the loop for final pruning decisions.
Where MindStudio Fits Into This Picture
The system above works well for individual developers using Claude Code locally. But if you want to scale this pattern to a team — or if you want a Claude Code memory system that connects to external data sources without maintaining Python scripts — MindStudio is worth looking at.
MindStudio is a no-code platform for building AI agents and workflows. It connects to 1,000+ tools out of the box, including Notion, Airtable, and Google Drive — all without custom code. You could build the same extraction-and-storage pipeline in MindStudio, triggered via webhook from a Claude Code hook, and store the results directly in a Notion database that your whole team can read and contribute to.
The practical advantage: your team’s collective Claude Code sessions contribute to a shared knowledge base, not just a local Obsidian vault. When a developer in one timezone solves a tricky problem, everyone else benefits from the captured lesson the next morning.
MindStudio also gives you access to multiple models through a single interface — so you could swap the extraction step to use GPT-4o one day and Claude another, comparing which model extracts more useful insights, without rewriting any infrastructure. You can try MindStudio free at mindstudio.ai.
For teams already building on Claude Code and exploring ways to extend it, MindStudio’s Agent Skills Plugin also lets any agent — including Claude Code agents — call MindStudio workflows as typed method calls. That opens up the possibility of Claude Code querying a shared memory store mid-session rather than only at start and end.
Common Issues and Fixes
The hook fires but nothing gets written
Check that ANTHROPIC_API_KEY is set in your shell environment. Hooks inherit the environment of the process that launched Claude Code. If you start Claude Code from a terminal where the key isn’t exported, the API call will silently fail. Add the key to your ~/.zshrc or ~/.bashrc.
The transcript file isn’t found
Claude Code stores session transcripts under a hashed project directory. The path looks like ~/.claude/projects/[hash]/[session-id].jsonl. The hash is derived from the absolute path of your project. If the script can’t find the file, add some debug logging to print what’s in CLAUDE_PROJECTS_DIR after a session completes.
Claude returns malformed JSON in the extraction step
The extraction prompt asks for JSON-only output, but Claude occasionally wraps it in markdown code fences. The re.sub line in the script strips those. If you still see parse errors, increase max_tokens — a long session with many insights can push the response over the default limit.
The CLAUDE.md context is too large
If the vault grows to hundreds of notes, listing them all in CLAUDE.md will eat into Claude’s context window. Instead, reference only the index file and instruct Claude to search specific subfolders when needed. For even better recall, consider building a simple semantic search over the vault using embeddings.
Frequently Asked Questions
Does Claude Code have built-in memory between sessions?
Not by default. Each Claude Code session starts fresh. The system described in this article uses hooks and external storage (Obsidian) to build persistent memory on top of Claude Code’s standard behavior.
What are Claude Code hooks and how do they work?
Claude Code hooks are shell commands or scripts that Claude Code calls at specific points in its lifecycle — before/after tool use, when a notification fires, or when a session ends. You define them in settings.json and they run automatically in the background. The session stop hook (Stop) is the most useful for memory capture.
Can I use a different note-taking tool instead of Obsidian?
Yes. Obsidian works well here because it reads and writes plain markdown files, which are easy for scripts to generate. But the same system works with any folder of markdown files — Logseq, Foam, or even a plain directory that you browse in VS Code. The extraction script writes standard markdown regardless of which tool you use to read it.
How much does the extraction step cost?
Each session extraction call to Claude costs based on the input tokens (your transcript) plus output tokens (the structured JSON). A typical 2,000-token session with a 500-token response runs well under $0.01 using Claude Haiku as the extraction model. Using Sonnet or Opus increases quality at higher cost. For most developers running a few sessions a day, the monthly cost is negligible.
Is the session transcript stored securely?
Claude Code stores session transcripts locally on your machine at ~/.claude/projects/. They don’t leave your system unless your hook scripts explicitly send them somewhere. When calling the Anthropic API for extraction, you are sending the session content to Anthropic’s servers — same as any API call you make during a session. If your sessions contain sensitive data, review Anthropic’s data handling policies and consider filtering transcript content before sending.
Can this system work across multiple machines?
With a local Obsidian vault and local scripts, the memory stays on one machine. To sync across machines, point your Obsidian vault to a cloud-synced folder (iCloud Drive, Dropbox, or Obsidian Sync). The scripts write to the vault path regardless, and the sync service handles replication. For team setups, a shared Notion database via MindStudio is a cleaner approach.
Key Takeaways
- Claude Code hooks let you run scripts at the end of every session — the
Stophook is your entry point for automatic memory capture. - A Python script can read the session transcript, call Claude to extract structured insights, and write them to Obsidian as markdown files.
- The vault grows with each session, capturing patterns, mistakes, decisions, and project context from real work.
- Feeding the vault back into Claude Code via
CLAUDE.mdcreates a genuine feedback loop: each session makes the next one better. - For teams, the same pattern scales with tools like MindStudio — shared storage replaces individual vaults, and the whole team benefits from collective session history.
- Maintenance matters: prune the vault regularly so signal doesn’t drown in noise.
The setup takes an hour or two upfront. After that, it runs on its own — and within a few weeks, you’ll have a knowledge base built from your actual coding sessions rather than documentation you wrote and forgot about. That’s what makes it worth building.