{"slug": "inspect-claude-code-sessions-with-lnav", "title": "Inspect Claude Code sessions with lnav", "summary": "A developer has created a format file that enables lnav, a log file viewer, to parse and query Claude Code session transcripts stored locally, making them searchable via SQLite. The tool allows users to analyze conversation turns, tool failures, cache economics, and slow tool calls, with support for recursive indexing of subagent transcripts using the -r flag.", "body_md": "# Inspect Claude Code sessions with lnav\n\nTip: LLM-generated article\n\nFor the most part, this article was LLM generated, especially the snippets and examples.\n\nI’m not sure if anyone will find this interesting but…\n\nYou can teach [lnav](https://lnav.org/) about Claude Code session stored on your machines and then have it properly parse them and make them queryable.\n\nHere’s the format file:\n\nDrop that in `~/.config/lnav/formats/claude-code/format.json`\n\nand open a session:\n\nReplace `<project>`\n\nwith a specific project or projects you work on or use `*`\n\nto index all sessions across all of your projects.\n\n## Querying without the TUI\n\n[Section titled Querying without the TUI](#querying-without-the-tui)\n\nThe interactive viewer is nice, but the real fun is that lnav puts every log file behind SQLite. You can run a query and print the result straight to your terminal:\n\n`-n`\n\nis headless mode, `-q`\n\nsilences the startup chatter, the `;`\n\nprefix marks a SQL command and `:write-table-to -`\n\ndumps the result set to stdout. There’s also `write-csv-to`\n\n, `write-json-to`\n\n, `write-jsonlines-to`\n\nand a few others if you want to pipe it somewhere. You can pass as many `-c`\n\npairs as you like and they’ll all run against a single index, or put them in a file and use `lnav -f queries.lnav`\n\n.\n\nTip: use -r\n\nSubagent transcripts live in a `subagents/`\n\nsubdirectory next to the session\nfile. Point lnav at the project directory with `-r`\n\nand it will pick up\neverything recursively. Without it, every query below silently excludes your\nsubagents.\n\nEverything below queries `claude_code_session`\n\n, the table lnav names after the format. I’ve left my own output out — point these at your transcripts instead, the shape of the answers is the interesting part.\n\n## So what’s in there?\n\n[Section titled So what’s in there?](#so-whats-in-there)\n\nA transcript is more than the conversation. Alongside `user`\n\nand `assistant`\n\nyou’ll find `attachment`\n\nentries (hook output, diagnostics, task reminders), `file-history-delta`\n\nrecords backing undo, `queue-operation`\n\nentries for messages typed while it was busy, and the occasional `pr-link`\n\n. It’s worth running this one first just to see how much of a transcript isn’t the conversation.\n\n## Where do the turns actually go?\n\n[Section titled Where do the turns actually go?](#where-do-the-turns-actually-go)\n\nEach assistant entry holds exactly one content block, so you can count what the model spends its turns doing:\n\nThree block types come back: `tool_use`\n\n, `thinking`\n\nand `text`\n\n. Only the last one is prose written for you to read, and the ratio between the three is a surprisingly good summary of how a session went.\n\n## Which tools fail?\n\n[Section titled Which tools fail?](#which-tools-fail)\n\nThis one needs a join: a `tool_use`\n\nblock in an assistant entry, matched to the `tool_result`\n\nblock in the user entry that answers it.\n\n`is_error`\n\non the result block is what turns this from a count into a rate. MCP tools and `Bash`\n\ntend to sit at the top; the `HAVING`\n\nclause keeps one-off tools from dominating with a 100% failure rate on a single call.\n\n## Cache economics\n\n[Section titled Cache economics](#cache-economics)\n\nToken usage is recorded per message, so the whole cost picture is one `GROUP BY`\n\n:\n\nGrouping by model matters because a session can switch models partway through, and the `<%`\n\nfilter drops the `<synthetic>`\n\nmessages Claude Code writes for things like interrupts. The column to look at is `cache_pct`\n\n— I was not expecting prompt caching to be doing quite as much of the work as it is.\n\n## The slowest tool calls are me\n\n[Section titled The slowest tool calls are me](#the-slowest-tool-calls-are-me)\n\nSame join as before, but subtracting the two timestamps instead of counting errors:\n\nRun this and the top of the list will almost certainly be `ExitPlanMode`\n\nand `AskUserQuestion`\n\n— the tools whose implementation is a human deciding something, sometimes the next morning. Add a `WHERE tool NOT IN (...)`\n\nif you want actual tool latency rather than a measure of your own response time.\n\n## Odds and ends\n\n[Section titled Odds and ends](#odds-and-ends)\n\nThe first word of every shell command it ran, which is a decent picture of how it explores a repo:\n\nHow much work gets delegated to subagents (this is the query that needs `-r`\n\n):\n\n`attributionAgent`\n\ngives you the agent type and `agentId`\n\nis unique per run, so `count(DISTINCT agentId)`\n\ntells you how many times each one was spawned. The main thread has no `agentId`\n\n, so it lands in the `(main thread)`\n\nbucket with a run count of zero.\n\nAnd a few more that need no explanation:\n\n`log_time`\n\n, `log_idle_msecs`\n\nand `log_opid`\n\ncome free with every lnav format. The last one is wired to `sessionId`\n\nin the format file above, so a merged view of many files can still be grouped back into individual sessions.\n\n## Assorted notes and findings\n\n[Section titled Assorted notes and findings](#assorted-notes-and-findings)\n\n**lnav can’t index into JSON arrays.** Nested objects flatten into `/`\n\n-joined column names, which is why `\"message/usage/output_tokens\"`\n\nworks (and why it needs the double quotes). Arrays don’t get that treatment: there is no `message/content/0/type`\n\ncolumn. Declare the array as `\"kind\": \"json\"`\n\nand reach into it with `jget(col, '/0/name')`\n\n. Every query above is shaped by that one limitation.\n\n** AS MATERIALIZED isn’t optional.** Both halves of the tool-call join work fine on their own and fail the moment you join them, with\n\n`invalid char in json text`\n\nand no clue which row caused it. SQLite flattens the subqueries and ends up evaluating `jget`\n\nagainst rows that the other side’s `WHERE`\n\nclause would have excluded, including the user entries whose `message.content`\n\nis a plain string rather than an array. `MATERIALIZED`\n\nforces the boundary and `json_valid()`\n\nhandles the rest. ** lnav -f script.lnav stops at the first failing query** and still exits 0. I lost ten queries to a typo in query three and didn’t notice.\n\n** --anonymize is partial.** Every\n\n`write-*-to`\n\ncommand accepts it, and it consistently pseudonymises usernames and path prefixes — a home directory comes out as the same made-up word in every row. But it left repository names and filenames alone when I tried it. Transcripts pick up whatever you happened to be working on, so read your output before you publish it rather than trusting the flag.", "url": "https://wpnews.pro/news/inspect-claude-code-sessions-with-lnav", "canonical_source": "https://blog.disintegrator.dev/posts/lnav-claude-code/", "published_at": "2026-07-31 22:40:00+00:00", "updated_at": "2026-07-31 22:52:23.555954+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["lnav", "Claude Code", "SQLite"], "alternates": {"html": "https://wpnews.pro/news/inspect-claude-code-sessions-with-lnav", "markdown": "https://wpnews.pro/news/inspect-claude-code-sessions-with-lnav.md", "text": "https://wpnews.pro/news/inspect-claude-code-sessions-with-lnav.txt", "jsonld": "https://wpnews.pro/news/inspect-claude-code-sessions-with-lnav.jsonld"}}