Guide
Claude Code session history: where it lives and how to search it
Every session you have ever run is already on your disk, in full. Finding anything in it is the hard part.
Where the history lives
Claude Code writes a complete transcript of every session — your prompts, the model’s replies, every tool call and its output — to your home directory:
~/.claude/projects/<encoded-project-path>/<session-uuid>.jsonl
The project directory name is the working directory’s absolute path with the separators turned
into dashes: a session run in /Users/you/dev/webapp lands under
-Users-you-dev-webapp/. Each session is one file named by a UUID, and newer Claude Code
versions add a <session-uuid>/subagents/ directory beside it for Task-tool
transcripts. Nothing about this is documented or guaranteed — the format has changed before and
will change again.
Two properties of these files matter for search. First, they are complete: costs, diffs, shell output, everything. Second, resuming a session copies the whole history into a new file, so a conversation you resumed twice exists as three overlapping files.
The built-in ways
claude --continue reopens the most recent conversation in the current project, and
claude --resume shows a picker of recent ones. That solves “yesterday’s
session, this repo.” It does not solve “the session where we fixed the reconnect bug,
sometime in June, in one of four repos” — there is no cross-project view and no way to search
by what was said.
The grep way
It’s all text, so grep works — sort of:
grep -rl "reconnect" ~/.claude/projects/ # which files mention it
grep -rn "reconnect" ~/.claude/projects/ | head # the lines themselves
Three things make this worse than it looks:
-
Every line is JSON. A “line” of conversation is a record wrapping
escaped content — a hit prints as a wall of
{"parentUuid":"…","message":{"content":[{"type":"text","text":"…with your match buried mid-string. Newlines in code are\n, quotes are\", so phrases and snippets often fail to match at all. - No grouping, no context. Hits arrive file by file, not session by session; a UUID filename tells you nothing; and reading around a hit means opening a multi-megabyte JSON line stream. Resumed sessions triple the noise, since each copy matches again.
- Everything matches. Injected context, system reminders, and tool payloads are in there too, so common words return hundreds of meaningless hits.
A step up is jq, which at least understands the records — for example, listing just
your own prompts:
jq -r 'select(.type=="user") | .message.content
| if type=="string" then . else (map(.text // empty) | join(" ")) end' \
~/.claude/projects/-Users-you-dev-webapp/*.jsonl | grep -i reconnect
That works until a record shape changes, and it is still one project, one record type, no ranking, no way to open the match in context. (For what the records actually look like, see the JSONL format guide.)
What “searchable” actually needs
The gap between grep and search is structure: an index that knows what a session is, which project
it belongs to, who said what, and what a hit means — so results group by session, rank by
relevance, filter by facts (tool:Bash, is:error,
agent:codex, before:2026-07), and open at the matched moment with the
conversation around it. That is a database job, not a text-scan job.
npx turnlog demo.)The indexed way
Turnlog is that index
Turnlog is a free, MIT-licensed CLI that parses your entire
~/.claude/projects/ (and Codex’s ~/.codex/sessions/) into a local
SQLite full-text index, served on a hardened localhost UI. Search composes operators with text,
resumed sessions read as one conversation, every hit opens as a turn-by-turn replay, and your
agent can query the same index over MCP. Nothing leaves your machine.
Related: what’s inside the JSONL · one file’s history across sessions · tracking costs honestly · how the other tools compare