# Inspect Claude Code sessions with lnav

> Source: <https://blog.disintegrator.dev/posts/lnav-claude-code/>
> Published: 2026-07-31 22:40:00+00:00

# Inspect Claude Code sessions with lnav

Tip: LLM-generated article

For the most part, this article was LLM generated, especially the snippets and examples.

I’m not sure if anyone will find this interesting but…

You 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.

Here’s the format file:

Drop that in `~/.config/lnav/formats/claude-code/format.json`

and open a session:

Replace `<project>`

with a specific project or projects you work on or use `*`

to index all sessions across all of your projects.

## Querying without the TUI

[Section titled Querying without the TUI](#querying-without-the-tui)

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

is headless mode, `-q`

silences the startup chatter, the `;`

prefix marks a SQL command and `:write-table-to -`

dumps the result set to stdout. There’s also `write-csv-to`

, `write-json-to`

, `write-jsonlines-to`

and a few others if you want to pipe it somewhere. You can pass as many `-c`

pairs as you like and they’ll all run against a single index, or put them in a file and use `lnav -f queries.lnav`

.

Tip: use -r

Subagent transcripts live in a `subagents/`

subdirectory next to the session
file. Point lnav at the project directory with `-r`

and it will pick up
everything recursively. Without it, every query below silently excludes your
subagents.

Everything below queries `claude_code_session`

, 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.

## So what’s in there?

[Section titled So what’s in there?](#so-whats-in-there)

A transcript is more than the conversation. Alongside `user`

and `assistant`

you’ll find `attachment`

entries (hook output, diagnostics, task reminders), `file-history-delta`

records backing undo, `queue-operation`

entries for messages typed while it was busy, and the occasional `pr-link`

. It’s worth running this one first just to see how much of a transcript isn’t the conversation.

## Where do the turns actually go?

[Section titled Where do the turns actually go?](#where-do-the-turns-actually-go)

Each assistant entry holds exactly one content block, so you can count what the model spends its turns doing:

Three block types come back: `tool_use`

, `thinking`

and `text`

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

## Which tools fail?

[Section titled Which tools fail?](#which-tools-fail)

This one needs a join: a `tool_use`

block in an assistant entry, matched to the `tool_result`

block in the user entry that answers it.

`is_error`

on the result block is what turns this from a count into a rate. MCP tools and `Bash`

tend to sit at the top; the `HAVING`

clause keeps one-off tools from dominating with a 100% failure rate on a single call.

## Cache economics

[Section titled Cache economics](#cache-economics)

Token usage is recorded per message, so the whole cost picture is one `GROUP BY`

:

Grouping by model matters because a session can switch models partway through, and the `<%`

filter drops the `<synthetic>`

messages Claude Code writes for things like interrupts. The column to look at is `cache_pct`

— I was not expecting prompt caching to be doing quite as much of the work as it is.

## The slowest tool calls are me

[Section titled The slowest tool calls are me](#the-slowest-tool-calls-are-me)

Same join as before, but subtracting the two timestamps instead of counting errors:

Run this and the top of the list will almost certainly be `ExitPlanMode`

and `AskUserQuestion`

— the tools whose implementation is a human deciding something, sometimes the next morning. Add a `WHERE tool NOT IN (...)`

if you want actual tool latency rather than a measure of your own response time.

## Odds and ends

[Section titled Odds and ends](#odds-and-ends)

The first word of every shell command it ran, which is a decent picture of how it explores a repo:

How much work gets delegated to subagents (this is the query that needs `-r`

):

`attributionAgent`

gives you the agent type and `agentId`

is unique per run, so `count(DISTINCT agentId)`

tells you how many times each one was spawned. The main thread has no `agentId`

, so it lands in the `(main thread)`

bucket with a run count of zero.

And a few more that need no explanation:

`log_time`

, `log_idle_msecs`

and `log_opid`

come free with every lnav format. The last one is wired to `sessionId`

in the format file above, so a merged view of many files can still be grouped back into individual sessions.

## Assorted notes and findings

[Section titled Assorted notes and findings](#assorted-notes-and-findings)

**lnav can’t index into JSON arrays.** Nested objects flatten into `/`

-joined column names, which is why `"message/usage/output_tokens"`

works (and why it needs the double quotes). Arrays don’t get that treatment: there is no `message/content/0/type`

column. Declare the array as `"kind": "json"`

and reach into it with `jget(col, '/0/name')`

. Every query above is shaped by that one limitation.

** 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

`invalid char in json text`

and no clue which row caused it. SQLite flattens the subqueries and ends up evaluating `jget`

against rows that the other side’s `WHERE`

clause would have excluded, including the user entries whose `message.content`

is a plain string rather than an array. `MATERIALIZED`

forces the boundary and `json_valid()`

handles 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.

** --anonymize is partial.** Every

`write-*-to`

command 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.
