Not a member? Use this link.
A memory you have to remember to use is not memory. It is a database with a search bar.
That was the gap. After eight hours of a day job, on the evening shift where I do my own building, I never remembered the search bar was there. I gave Claude Code that memory last month: a mem0_search tool. Then I spent a month barely using it, because using it meant remembering it existed.
So I spent one evening wiring it into the session lifecycle itself. Now recall happens when a session opens and capture happens when it closes — both without me. Claude walks into every session already knowing what we decided last week, and closes it having written down what we decided today.
I have not typed "search your memory" in a month.
Three hooks do it. Here is the wiring.
TLDR: A Claude Code shell-command hook cannot call an MCP tool. It can only inject text. So my SessionStart hook does not do the recall — it injects an instruction telling Claude to do it, with the tools Claude already has. SessionEnd spawns a detached Python process that extracts the session's decisions with Gemini and writes them to Pinecone, after my terminal is already closed. Three scripts, around 250 lines, zero ongoing effort.
The realization: hooks inject, they don't execute
The first thing I got wrong was assuming a hook could run mem0_search itself and paste the result into context.
It can't — not the kind of hook I am using. A hook that is a shell command, which is what all three of mine are, cannot call an MCP tool. It has no MCP client. The only thing it can hand back is text: a small JSON object with an additionalContext field.
(Newer Claude Code does have an mcp_tool hook type that calls a tool directly. But a blind, unconditional tool call is not what I want here — I want Claude to decide whether and how to recall, with judgment. A command hook that injects an instruction gives me exactly that.)
That sounds like a wall. It is actually the design.
A shell-command hook does not perform the recall. It writes a prompt. It injects an instruction — "before you answer, search your memory for X" — and Claude, seeing that instruction sitting in context, runs the search with the mem0_search tool it already has. The hook is a prompt-writer. Claude is still the one doing the work, with full judgment about how to do it.
Once that clicked, all three hooks got simple. Two of them write recall instructions. One of them captures. None of them touch mem0 directly except the last — and even that one runs outside the session entirely.
Hook 1: recall the moment a session opens
SessionStart fires when I open Claude Code. My hook here is a bash script that does one cheap thing: look at the current directory, and if it is one of my projects, inject a recall instruction scoped to that project.
BASENAME=$(basename "$CWD")
# ...match BASENAME against my known project slugs, set $SLUG...
MSG="[session-context] cwd is project '$SLUG'. Before answering the
first prompt, call mem0_search with query=\"$SLUG\" for past
decisions, preferences, and in-flight work."
jq -nc --arg msg "$MSG" \
'{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $msg}}'
That is the whole mechanism. The JSON goes back to Claude Code, the additionalContext string lands in the session as if it had always been there, and because the instruction is explicit and imperative, Claude runs the search before it answers my first message. By the time I have typed anything, the recall results are already in context.
The scoping is deterministic on purpose. The script matches the folder name against a hard-coded list of my project slugs — no LLM call inside the hook, no latency, no cost. In a project folder it recalls that project. In a random folder it falls back to a generic recall keyed on the folder name. I gave the hook a five-second timeout, and it never comes close to it.
The payoff shows up as a kind of friction quietly disappearing. I no longer re-explain decisions I already made — which database a project uses, why an endpoint is shaped the way it is, the API quirk that cost me an evening last month. The recall puts those back in front of Claude before its first answer, so it builds on them instead of slowly reinventing them.
Side note: if you'd rather have this whole setup broken into a few short, copy-paste steps in your inbox, I turned it into The Claude Code Memory Starter — a free email series. Back to the hooks.
Hook 2: catch the project I name mid-conversation
SessionStart covers where I am sitting. But half my prompts mention a different project than the folder I am in — "wait, how did I handle inbound email in subhook?"
So the second hook runs on UserPromptSubmit, before each message reaches Claude. It reads the prompt text, lowercases it, and scans for any known project slug as a whole word.
for s in $HUB_SLUGS; do
if echo "$PROMPT_LC" | grep -qE "(^|[^a-z0-9])$s([^a-z0-9]|\$)"; then
MATCHED="$MATCHED $s"
fi
done
The whole-word boundary in that regex is load-bearing. Without it, a short slug matches inside longer unrelated words and ordinary prompts trigger recalls all day. With it, only a real mention counts.
If a slug matches, the hook injects the same kind of recall instruction — pull context for that project before answering. If nothing matches, the hook exits silently and adds nothing at all. Most prompts hit the silent path.
This is the part I notice most in daily use. I name a project and Claude already has its history before it responds, even when that repo is three directories away from where I am actually working.
Hook 3: capture without making me wait
The first two hooks are reads. Reads are easy — they are fast, and a few hundred milliseconds at session start is invisible.
The third hook is the write, and writes are where this got ugly.
SessionEnd fires when I close a session. The work it wants to do is not fast: read the whole transcript, send it to an LLM to extract what actually mattered, dedupe each item against the vector store, write the survivors. That is several seconds of work. If the hook does it inline, my terminal hangs for several seconds every single time I close it. Not acceptable.
So the SessionEnd hook does almost nothing itself. It spawns a detached background process and returns immediately:
(
"$PYTHON_BIN" "$SCRIPT_PATH" \
"$TRANSCRIPT_PATH" "$SESSION_ID" "$HOOK_EVENT" "$CWD" \
</dev/null > "$LOG_FILE" 2>&1 &
disown
) >/dev/null 2>&1
The extraction runs after my terminal is already gone. I find out whether it worked by reading a log file later, never by waiting on it.
Three things bit me here. They are the reason this section is the longest one.
Parallel runs. At the end of an evening I close three or four sessions in a burst. Without a lock, that is three or four extractors hammering the vector store at once.
(My last memory article had a footnote about a "helpful" hook that once spawned seven parallel Claude instances. I have held a grudge against unguarded hooks ever since.) The fix is two locks: a global one created with mkdir — atomic because the filesystem either creates the directory or fails, with no gap between the check and the create, unlike testing for a lockfile and then writing it — plus a per-session lock with a 120-second cooldown so one session cannot double-fire.
Inherited environment. The extractor is a plain Python script, but spawned from inside Claude Code it inherits Claude Code's entire environment — ANTHROPIC_BASE_URL, an OAuth token, a dozen CLAUDE_CODE_* variables. That environment makes the subprocess behave as if it is still running inside Claude. The fix is one unset block before the Python runs, clearing every inherited Claude variable. This one cost me a confused hour.
Key source. The extractor needs API keys. I could hard-code them into the script. Instead it reads them straight out of ~/.claude.json — the same file the live mem0 MCP server already reads its own keys from. One source of truth. When I rotate a key I rotate it in one place, and both the hook and the server pick it up.
What the extractor actually keeps
The background script sends the transcript to Gemini 2.5 Flash Lite with a tight extraction prompt: keep architectural decisions and their reasoning, keep preferences, keep root causes of bugs, keep project-specific facts. Skip transient state, skip routine procedure. Flash Lite sits on the free tier, so a chatty day of building costs nothing — and transcript extraction is genuinely not a job that needs a frontier model. A small OpenAI model is wired in as a fallback for the days Gemini fails.
Each surviving item comes back as structured JSON — the fact, a project scope, and a category like decision or learning. In practice a memory looks like one short sentence: "subhook uses Postmark inbound for subdomain mail because Cloudflare Email Routing only forwards the apex on the free plan," tagged {project: subhook, category: decision}. Before writing anything, the script searches the vector store for that exact text and skips it if something already sits above 0.88 cosine similarity. That dedupe is the line between a memory that compounds and a memory that fills up with a hundred near-identical entries.
What survives gets written to Pinecone with infer=False — store the text verbatim. The extraction already happened, in the Gemini step. I do not want the vector store re-paraphrasing facts I already shaped into the form I wanted them in.
If you copy one idea from this
Not the project-slug list. Not Pinecone, not Gemini. Those are my plumbing, and yours will be different.
Copy this: a hook that injects an instruction beats a hook that runs a command. A hook that runs git status and returns the output hands Claude a fact. A hook that injects "before answering, check X and recall Y" hands Claude a task — carried out with the full toolset and judgment Claude already has. The first is static. The second is behavior.
Once I started seeing shell-command hooks as a place to write prompts instead of a place to run scripts, the memory wiring stopped looking like a hack and started looking like the obvious shape. Recall on the way in. Capture on the way out. And an evening-tired version of me who never has to think about either one.
If you want the layer this builds on, the same lifecycle thinking shows up in how I put a code reviewer in front of every git push with a Claude Code subagent — another small piece of glue that runs without me thinking about it.
External Sources
- Claude Code hooks reference — official lifecycle event and JSON schema docs
- mem0 on GitHub — the memory layer this builds on
- Pinecone documentation — managed vector database getting-started guide
- Gemini API documentation — Google's developer docs for Gemini models
- Gemini cookbook — examples and guides for the Gemini API
If you want the starter version of this to land in your inbox, The Claude Code Memory Starter walks you through it over a few short emails — free. The memory layer this one builds on is the earlier article on adding persistent semantic memory to Claude Code.
Target publication: Generative AI (~50K followers) [PRIMARY for the AI/Claude Code angle] · Level Up Coding (~60K followers) [SECONDARY]
Tags: Claude Code · AI · Developer Tools · Programming · Productivity
I build small tools and kits for solo creators. You can find them here: https://danielrusnok.gumroad.com



Top comments (0)