Back to Blog

How I Built Persistent AI Memory for Claude Code Using Graphiti and FalkorDB

Notion
11 min read
AIClaudeDeveloper-ToolsTutorialhomelabsOpen-SourceLLM

Every 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 responses

Every 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-mcp

Create 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: local

Create .env

OPENAI_API_KEY=sk-your-key-here
FALKORDB_PASSWORD=choose-a-strong-password

Launch it

docker compose up -d

Verify

# Health check
curl http://localhost:8000/health
# Should return: {"status": "healthy"}
 
# FalkorDB browser
# Open http://localhost:3000 in your browser

If 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/mcp

Verify the connection:

claude mcp list
# Should show: graphiti-memory: ... - Connected

Important 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:

  1. Reads the transcript JSONL file
  2. Extracts new user + assistant messages since last run
  3. Sends them to Graphiti via the MCP protocol
  4. Tracks its offset so it only processes new content

Create the hook script

mkdir -p ~/.claude/hooks

Create ~/.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 0

Make it executable:

chmod +x ~/.claude/hooks/graphiti-ingest.sh

Configure 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.py

The 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:

  1. Reads the JSONL transcript from the offset where it last stopped
  2. Filters for user and assistant message types
  3. Extracts text content (skipping system reminders, tool outputs, thinking blocks)
  4. Initializes an MCP session with Graphiti via JSON-RPC
  5. Calls add_memory with the conversation text
  6. 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_project not my-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_memory via 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


Share this post

Help this article travel further

8share actions ready

One tap opens the share sheet or pre-fills the post for the platform you want.