How I Built Persistent AI Memory for Claude Code Using Graphiti and FalkorDB
NotionEvery Claude Code session starts with amnesia. You explain your architecture, debug the same issue twice, re-describe your infrastructure. The AI forgets everything the moment you close the terminal.
I fixed this by building a self-hosted knowledge graph that captures every conversation automatically and feeds context back into future sessions. No manual saving. No hoping the AI remembers to call a tool. A system-level solution that works silently in the background.
This guide covers the full setup: deploying Graphiti and FalkorDB, connecting them to Claude Code via MCP, building a Stop hook that auto-captures every conversation, and backfilling your entire session history.
What You're Building
A pipeline that looks like this:
Claude Code Session
|
| (Stop hook fires after every response)
v
graphiti-ingest.sh
|
| (extracts user + assistant text from JSONL transcript)
v
Graphiti MCP Server (HTTP)
|
| (OpenAI gpt-4o-mini extracts entities + relationships)
v
FalkorDB (graph database)
|
| (nodes, edges, episodes stored persistently)
v
Next Claude Code Session
|
| (searches graph at session start for relevant context)
v
Smarter AI responsesEvery conversation gets captured. Every future session gets context. Zero manual effort.
Prerequisites
- A Linux server (bare metal, VM, LXC container, or cloud instance)
- Docker and Docker Compose
- An OpenAI API key (gpt-4o-mini costs ~$1-3/month for this use case)
- Claude Code installed locally
Deployment Options
This guide uses Docker Compose. Adapt the host to whatever you have.
Step 1: Deploy Graphiti + FalkorDB
Create your project directory
mkdir ~/graphiti-mcp && cd ~/graphiti-mcpCreate docker-compose.yaml
services:
graphiti-falkordb:
image: zepai/knowledge-graph-mcp:standalone
ports:
- "6379:6379" # FalkorDB (Redis protocol)
- "3000:3000" # FalkorDB Browser UI
- "8000:8000" # Graphiti MCP Server
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY}
- MODEL_NAME=gpt-4o-mini
- FALKORDB_URI=redis://localhost:6379
- FALKORDB_PASSWORD=${FALKORDB_PASSWORD}
- FALKORDB_DATABASE=default_db
- GRAPHITI_GROUP_ID=default
- SEMAPHORE_LIMIT=3
- GRAPHITI_TELEMETRY_ENABLED=false
volumes:
- falkordb-data:/data
- mcp-logs:/app/mcp/logs
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
restart: unless-stopped
volumes:
falkordb-data:
driver: local
mcp-logs:
driver: localCreate .env
OPENAI_API_KEY=sk-your-key-here
FALKORDB_PASSWORD=choose-a-strong-passwordLaunch it
docker compose up -dVerify
# Health check
curl http://localhost:8000/health
# Should return: {"status": "healthy"}
# FalkorDB browser
# Open http://localhost:3000 in your browserIf you're deploying on a remote server, replace localhost with your server's IP address throughout this guide.
Step 2: Connect Claude Code to Graphiti
Add the MCP server at user scope so it works across all projects:
claude mcp add --transport http --scope user graphiti-memory http://YOUR_SERVER_IP:8000/mcpVerify the connection:
claude mcp list
# Should show: graphiti-memory: ... - ConnectedImportant gotcha: The URL must end with /mcp (no trailing slash). The server 307-redirects /mcp/ which breaks the MCP handshake.
Scope matters: Use --scope user so the MCP server is available in every project directory. If you use --scope local (the default), it only works in the current project.
Step 3: Build the Auto-Ingestion Hook
This is the key piece. Claude Code supports lifecycle hooks that fire at specific events. The Stop hook fires after every AI response and receives the path to the session transcript.
We write a bash script that:
- Reads the transcript JSONL file
- Extracts new user + assistant messages since last run
- Sends them to Graphiti via the MCP protocol
- Tracks its offset so it only processes new content
Create the hook script
mkdir -p ~/.claude/hooksCreate ~/.claude/hooks/graphiti-ingest.sh:
#!/bin/bash
# graphiti-ingest.sh - Stop hook for auto-ingesting conversations
GRAPHITI_MCP="http://YOUR_SERVER_IP:8000/mcp"
STATE_DIR="$HOME/.graphiti-hook-state"
mkdir -p "$STATE_DIR"
# Read hook input from stdin
INPUT=$(cat)
TRANSCRIPT_PATH=$(echo "$INPUT" | python3 -c \
"import sys,json; print(json.load(sys.stdin).get('transcript_path',''))" \
2>/dev/null)
SESSION_ID=$(echo "$INPUT" | python3 -c \
"import sys,json; print(json.load(sys.stdin).get('session_id',''))" \
2>/dev/null)
CWD=$(echo "$INPUT" | python3 -c \
"import sys,json; print(json.load(sys.stdin).get('cwd',''))" \
2>/dev/null)
# Bail if no transcript
if [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIPT_PATH" ]; then
exit 0
fi
# Track lines already processed
STATE_FILE="$STATE_DIR/$SESSION_ID.offset"
LAST_OFFSET=0
if [ -f "$STATE_FILE" ]; then
LAST_OFFSET=$(cat "$STATE_FILE")
fi
# Extract new conversation content
EXTRACT=$(python3 -c "
import json, sys, os
transcript = sys.argv[1]
offset = int(sys.argv[2])
cwd = sys.argv[3]
lines = open(transcript).readlines()
total = len(lines)
if offset >= total:
sys.exit(0)
user_texts = []
assistant_texts = []
for line in lines[offset:]:
try:
d = json.loads(line)
except:
continue
if d.get('type') == 'user':
msg = d.get('message', {})
content = msg.get('content', '')
if isinstance(content, list):
for c in content:
if isinstance(c, dict) and c.get('type') == 'text':
text = c['text'].strip()
if text and not text.startswith('<system-reminder>'):
user_texts.append(text[:500])
elif isinstance(content, str) and content.strip():
user_texts.append(content.strip()[:500])
elif d.get('type') == 'assistant':
for c in d.get('message', {}).get('content', []):
if c.get('type') == 'text' and len(c.get('text', '')) > 20:
assistant_texts.append(c['text'].strip()[:2000])
if not user_texts and not assistant_texts:
print(json.dumps({'empty': True, 'total': total}))
sys.exit(0)
parts = []
for t in user_texts[-3:]:
parts.append(f'User: {t}')
for t in assistant_texts[-2:]:
parts.append(f'Assistant: {t}')
body = '\\n\\n'.join(parts)
project = os.path.basename(cwd) if cwd else 'unknown'
print(json.dumps({
'empty': False,
'body': body,
'project': project,
'total': total
}))
" "$TRANSCRIPT_PATH" "$LAST_OFFSET" "$CWD" 2>/dev/null)
# Check if empty
IS_EMPTY=$(echo "$EXTRACT" | python3 -c \
"import sys,json; print(json.load(sys.stdin).get('empty', True))" \
2>/dev/null)
if [ "$IS_EMPTY" = "True" ] || [ -z "$EXTRACT" ]; then
TOTAL=$(echo "$EXTRACT" | python3 -c \
"import sys,json; print(json.load(sys.stdin).get('total', 0))" \
2>/dev/null)
[ -n "$TOTAL" ] && [ "$TOTAL" -gt 0 ] && echo "$TOTAL" > "$STATE_FILE"
exit 0
fi
BODY=$(echo "$EXTRACT" | python3 -c \
"import sys,json; print(json.load(sys.stdin)['body'])" 2>/dev/null)
PROJECT=$(echo "$EXTRACT" | python3 -c \
"import sys,json; print(json.load(sys.stdin)['project'])" 2>/dev/null)
TOTAL=$(echo "$EXTRACT" | python3 -c \
"import sys,json; print(json.load(sys.stdin)['total'])" 2>/dev/null)
GROUP="default"
# Health check
if ! curl -s --max-time 2 "http://YOUR_SERVER_IP:8000/health" \
2>/dev/null | grep -q "healthy"; then
echo "$TOTAL" > "$STATE_FILE"
exit 0
fi
# Initialize MCP session
SESSION=$(curl -s --max-time 5 -D - -X POST "$GRAPHITI_MCP" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"graphiti-hook","version":"1.0"}},"id":0}' \
2>/dev/null | grep -i "mcp-session-id" | awk '{print $2}' | tr -d '\r')
if [ -z "$SESSION" ]; then
echo "$TOTAL" > "$STATE_FILE"
exit 0
fi
# Send to Graphiti
ESCAPED_BODY=$(echo "$BODY" | python3 -c \
"import sys,json; print(json.dumps(sys.stdin.read().strip()))" 2>/dev/null)
SLUG=$(echo "$PROJECT" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | cut -c1-30)
NAME="session-${SLUG}-$(date +%s)"
PAYLOAD="{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"add_memory\",\"arguments\":{\"name\":\"$NAME\",\"episode_body\":$ESCAPED_BODY,\"group_id\":\"$GROUP\",\"source\":\"text\",\"source_description\":\"Claude Code auto-ingested from $PROJECT\"}},\"id\":$RANDOM}"
curl -s --max-time 30 -X POST "$GRAPHITI_MCP" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "mcp-session-id: $SESSION" \
-d "$PAYLOAD" >/dev/null 2>&1
echo "$TOTAL" > "$STATE_FILE"
exit 0Make it executable:
chmod +x ~/.claude/hooks/graphiti-ingest.shConfigure the hook in Claude Code settings
Edit ~/.claude/settings.json and add the hooks section:
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "/path/to/your/.claude/hooks/graphiti-ingest.sh",
"timeout": 30
}
]
}
]
}
}Replace the path with your actual home directory path.
Step 4: Test It
Start a new Claude Code session and have a conversation. After each response, the Stop hook fires silently in the background.
To verify ingestion is working:
# Check hook state files (one per session)
ls -lt ~/.graphiti-hook-state/
# Search the graph for something you discussed
curl -s -X POST http://YOUR_SERVER_IP:8000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":0}'Or open the FalkorDB browser at http://YOUR_SERVER_IP:3000 to visually explore the knowledge graph.
Step 5: Backfill Historical Sessions
Claude Code stores all session transcripts as JSONL files in ~/.claude/projects/. You can backfill your entire conversation history into the graph.
Create backfill-sessions.py:
#!/usr/bin/env python3
"""Backfill Claude Code session history into Graphiti."""
import json, os, sys, time, hashlib, requests
from pathlib import Path
GRAPHITI_MCP = "http://YOUR_SERVER_IP:8000/mcp"
PROJECTS_DIR = os.path.expanduser("~/.claude/projects")
STATE_FILE = os.path.expanduser("~/.graphiti-backfill-state.json")
def extract_conversation(filepath):
user_texts, assistant_texts = [], []
with open(filepath) as f:
for line in f:
try:
d = json.loads(line)
except:
continue
if d.get("type") == "user":
content = d.get("message", {}).get("content", "")
if isinstance(content, list):
for c in content:
if isinstance(c, dict) and c.get("type") == "text":
t = c["text"].strip()
if t and not t.startswith("<system"):
user_texts.append(t[:500])
elif d.get("type") == "assistant":
for c in d.get("message", {}).get("content", []):
if c.get("type") == "text" and len(c.get("text","")) > 20:
assistant_texts.append(c["text"][:2000])
if not user_texts and not assistant_texts:
return ""
parts = [f"User: {t}" for t in user_texts[-5:]]
parts += [f"Assistant: {t}" for t in assistant_texts[-3:]]
return "\n\n".join(parts)
def get_mcp_session():
resp = requests.post(GRAPHITI_MCP, json={
"jsonrpc": "2.0", "method": "initialize",
"params": {"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "backfill", "version": "1.0"}},
"id": 0
}, headers={"Accept": "application/json, text/event-stream"}, timeout=10)
return resp.headers.get("mcp-session-id")
def send_episode(session_id, name, body, group_id):
resp = requests.post(GRAPHITI_MCP, json={
"jsonrpc": "2.0", "method": "tools/call",
"params": {"name": "add_memory", "arguments": {
"name": name, "episode_body": body,
"group_id": group_id, "source": "text",
"source_description": f"Backfill from {name}"
}}, "id": hash(name) % 100000
}, headers={"Accept": "application/json, text/event-stream",
"mcp-session-id": session_id}, timeout=120)
return "result" in resp.text
# Load state
state = json.load(open(STATE_FILE)) if os.path.exists(STATE_FILE) else {"processed": {}}
processed = state["processed"]
# Find files
files = [os.path.join(r, f)
for r, _, fs in os.walk(PROJECTS_DIR)
for f in fs
if f.endswith(".jsonl") and os.path.getsize(os.path.join(r, f)) > 1000
and "subagents" not in r
and os.path.join(r, f) not in processed]
print(f"Files to process: {len(files)}")
session_id = get_mcp_session()
success = 0
for i, fp in enumerate(files):
conv = extract_conversation(fp)
if len(conv) < 50:
processed[fp] = "skipped"
continue
if len(conv) > 8000:
conv = conv[:8000]
fhash = hashlib.md5(fp.encode()).hexdigest()[:8]
ok = send_episode(session_id, f"backfill-{fhash}", conv, "default")
processed[fp] = "ok" if ok else "failed"
if ok: success += 1
print(f" [{i+1}/{len(files)}] {'OK' if ok else 'FAIL'}")
if (i + 1) % 10 == 0:
json.dump({"processed": processed}, open(STATE_FILE, "w"))
if (i + 1) % 50 == 0: # Refresh MCP session
session_id = get_mcp_session()
time.sleep(4) # Rate limit
json.dump({"processed": processed}, open(STATE_FILE, "w"))
print(f"Done! {success} ingested, {len(files) - success} skipped/failed")Run it:
pip install requests
python3 backfill-sessions.pyThe script is resumable. If it crashes or you stop it, just run it again and it picks up where it left off.
Step 6: Optional CLAUDE.md for Context Retrieval
The hook handles writing to the graph. To also read from it at session start, add this to your ~/CLAUDE.md:
## Graphiti Knowledge Graph
You have access to a persistent knowledge graph via `mcp__graphiti-memory__*` tools.
At session start, call `search_nodes` with the current project name,
`group_ids: ["default"]`, `max_nodes: 5`. Silently absorb relevant context.This is optional. The auto-ingestion works regardless.
How It Works Under the Hood
The Stop hook fires after every Claude Code response. Claude Code passes a JSON object to stdin containing transcript_path (the JSONL session file) and session_id. The script:
- Reads the JSONL transcript from the offset where it last stopped
- Filters for
userandassistantmessage types - Extracts text content (skipping system reminders, tool outputs, thinking blocks)
- Initializes an MCP session with Graphiti via JSON-RPC
- Calls
add_memorywith the conversation text - Saves the new offset for next time Graphiti receives the text and runs it through OpenAI gpt-4o-mini for entity extraction. It identifies people, projects, tools, concepts, and the relationships between them. These become nodes and edges in FalkorDB's graph database.
FalkorDB stores everything persistently in a Redis-compatible graph structure. You can query it with Cypher, through the MCP tools, or visually through the browser UI.
Gotchas We Hit
Things that burned time so you don't have to:
- **MCP URL must be
**/mcp**not ****/mcp/**. The trailing slash causes a 307 redirect that breaks the handshake. - Group IDs must use underscores, not hyphens. Hyphens break RediSearch queries in FalkorDB. Use
my_projectnotmy-project. **--scope user**** not ****--scope local**when adding the MCP server. Local scope only works in one project directory.- CLAUDE.md** instructions alone are unreliable**. We tried telling Claude to call
add_memoryvia CLAUDE.md instructions. It worked sometimes, but the AI deprioritizes it in favor of the user's actual task. The Stop hook approach is 100% consistent because it bypasses the AI entirely. **~/.claude/CLAUDE.md**** is not loaded by Claude Code**. Global instructions must go in~/CLAUDE.md(home directory root). The.claude/directory is for settings and plugins.- New sessions required for config changes. Hook config and CLAUDE.md are loaded at session start. Existing sessions won't pick up changes.
Cost
Graphiti uses OpenAI gpt-4o-mini for entity extraction on every add_memory call.
What's Next
- Claude Desktop integration: Claude Desktop has no hook system, so auto-ingestion requires a file watcher on its conversation logs.
- Multi-group routing: Map project directories to different group IDs for isolated knowledge domains.
- Automated backups: Schedule FalkorDB BGSAVE + copy to external storage.
- Graph pruning: Periodically review and remove outdated facts.
Key Resources
- Graphiti by Zep AI (GitHub)
- Knowledge Graph MCP Server (Docker image:
zepai/knowledge-graph-mcp:standalone) - FalkorDB (graph database)
- Claude Code Hooks Reference
- SNooZyy2/graphiti-claude-memory (community hooks implementation)
- thedotmack/claude-mem (alternative memory architecture)
Share this post
Help this article travel further
One tap opens the share sheet or pre-fills the post for the platform you want.