Top of page

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 about Claude Code session stored on your machines and then have it properly parse them and make them queryable.

Here’s the format file:

claude-code.json
{
"$schema": "https://lnav.org/schemas/format-v1.schema.json",
"claude_code_session": {
"title": "Claude Code session transcript",
"description": "JSONL session transcripts written by Claude Code under ~/.claude/projects/<encoded-cwd>/<session-uuid>.jsonl and .../<session-uuid>/subagents/agent-*.jsonl",
"url": "https://docs.claude.com/en/docs/claude-code",
"json": true,
"file-pattern": "\\.claude/projects/.*\\.jsonl$",
"hide-extra": true,
"timestamp-field": "timestamp",
"opid-field": "sessionId",
"level-field": "type",
"level": {
"debug": "^(attachment|file-history-delta|file-history-snapshot|queue-operation)$",
"info": "^(user|assistant|started|result)$",
"notice": "^(system|pr-link|frame-link|fork-context-ref)$"
},
"line-format": [
{ "field": "__timestamp__" },
" ",
{
"field": "type",
"min-width": 10,
"max-width": 10,
"overflow": "truncate"
},
{
"field": "subtype",
"default-value": "",
"prefix": " [",
"suffix": "]"
},
{
"field": "operation",
"default-value": "",
"prefix": " [",
"suffix": "]"
},
{
"field": "attachment/type",
"default-value": "",
"prefix": " [",
"suffix": "]"
},
{
"field": "durationMs",
"default-value": "",
"prefix": " ",
"suffix": "ms"
},
{ "field": "attributionSkill", "default-value": "", "prefix": " skill=" },
{ "field": "attributionAgent", "default-value": "", "prefix": " agent=" },
{ "field": "agentId", "default-value": "", "prefix": " agentId=" },
" ",
{ "field": "message/content", "default-value": "" },
{ "field": "content", "default-value": "" },
{ "field": "prUrl", "default-value": "" },
{ "field": "trackingPath", "default-value": "" },
{
"field": "attachment",
"default-value": "",
"max-width": 400,
"overflow": "truncate"
}
],
"value": {
"type": {
"kind": "string",
"identifier": true,
"description": "Transcript entry kind (user, assistant, attachment, system, ...)"
},
"subtype": {
"kind": "string",
"identifier": true,
"description": "Sub-kind of a system entry (turn_duration, local_command, stop_hook_summary, ...)"
},
"operation": {
"kind": "string",
"identifier": true,
"description": "Queue operation (enqueue, dequeue, ...)"
},
"attachment/type": {
"kind": "string",
"identifier": true,
"description": "Attachment kind (hook_success, task_reminder, diagnostics, skill_listing, ...)"
},
"attachment": {
"kind": "json",
"description": "Full attachment payload"
},
"message/content": {
"kind": "json",
"description": "Message content: array of text/thinking/tool_use/tool_result blocks, or a bare string"
},
"content": {
"kind": "string",
"description": "Plain-text body of system and queue-operation entries"
},
"durationMs": {
"kind": "integer",
"unit": { "field": "ms" },
"description": "Turn duration reported by system/turn_duration entries"
},
"messageCount": { "kind": "integer", "hidden": true },
"message/role": { "kind": "string", "identifier": true, "hidden": true },
"message/model": { "kind": "string", "identifier": true, "hidden": true },
"message/id": { "kind": "string", "identifier": true, "hidden": true },
"message/stop_reason": {
"kind": "string",
"identifier": true,
"hidden": true
},
"message/usage/input_tokens": { "kind": "integer", "hidden": true },
"message/usage/output_tokens": { "kind": "integer", "hidden": true },
"message/usage/cache_read_input_tokens": {
"kind": "integer",
"hidden": true
},
"message/usage/cache_creation_input_tokens": {
"kind": "integer",
"hidden": true
},
"message/usage/service_tier": {
"kind": "string",
"identifier": true,
"hidden": true
},
"toolUseResult": {
"kind": "json",
"hidden": true,
"description": "Structured result of the tool call this user entry is replying to"
},
"uuid": { "kind": "string", "identifier": true, "hidden": true },
"parentUuid": {
"kind": "string",
"identifier": true,
"foreign-key": true,
"hidden": true
},
"sessionId": { "kind": "string", "identifier": true, "hidden": true },
"requestId": { "kind": "string", "identifier": true, "hidden": true },
"promptId": { "kind": "string", "identifier": true, "hidden": true },
"messageId": {
"kind": "string",
"identifier": true,
"foreign-key": true,
"hidden": true
},
"snapshotMessageId": {
"kind": "string",
"identifier": true,
"foreign-key": true,
"hidden": true
},
"sourceToolAssistantUUID": {
"kind": "string",
"identifier": true,
"foreign-key": true,
"hidden": true
},
"sourceToolUseID": {
"kind": "string",
"identifier": true,
"hidden": true
},
"agentId": { "kind": "string", "identifier": true },
"cwd": { "kind": "string", "identifier": true, "hidden": true },
"gitBranch": { "kind": "string", "identifier": true, "hidden": true },
"version": { "kind": "string", "identifier": true, "hidden": true },
"entrypoint": { "kind": "string", "identifier": true, "hidden": true },
"userType": { "kind": "string", "identifier": true, "hidden": true },
"sessionKind": { "kind": "string", "identifier": true, "hidden": true },
"slug": { "kind": "string", "identifier": true, "hidden": true },
"effort": { "kind": "string", "identifier": true, "hidden": true },
"mode": { "kind": "string", "identifier": true, "hidden": true },
"permissionMode": {
"kind": "string",
"identifier": true,
"hidden": true
},
"promptSource": { "kind": "string", "identifier": true, "hidden": true },
"origin": { "kind": "string", "identifier": true, "hidden": true },
"level": { "kind": "string", "identifier": true, "hidden": true },
"isSidechain": { "kind": "boolean", "hidden": true },
"isMeta": { "kind": "boolean", "hidden": true },
"isApiErrorMessage": { "kind": "boolean", "hidden": true },
"apiErrorStatus": { "kind": "integer", "hidden": true },
"attributionSkill": { "kind": "string", "identifier": true },
"attributionAgent": { "kind": "string", "identifier": true },
"attributionPlugin": {
"kind": "string",
"identifier": true,
"hidden": true
},
"attributionMcpServer": {
"kind": "string",
"identifier": true,
"hidden": true
},
"attributionMcpTool": {
"kind": "string",
"identifier": true,
"hidden": true
},
"toolDenialKind": {
"kind": "string",
"identifier": true,
"hidden": true
},
"stopReason": { "kind": "string", "identifier": true, "hidden": true },
"preventedContinuation": { "kind": "boolean", "hidden": true },
"hookCount": { "kind": "integer", "hidden": true },
"prNumber": { "kind": "integer", "hidden": true },
"prUrl": { "kind": "string", "identifier": true },
"prRepository": { "kind": "string", "identifier": true, "hidden": true },
"trackingPath": { "kind": "string", "identifier": true },
"backup/backupFileName": {
"kind": "string",
"identifier": true,
"hidden": true
},
"backup/version": { "kind": "integer", "hidden": true },
"title": { "kind": "string", "hidden": true },
"frameUrl": { "kind": "string", "identifier": true, "hidden": true },
"path": { "kind": "string", "identifier": true, "hidden": true },
"url": { "kind": "string", "identifier": true, "hidden": true }
},
"highlights": {
"tool-use": {
"pattern": "\"type\"\\s*:\\s*\"tool_use\"\\s*,\\s*\"id\"\\s*:\\s*\"[^\"]*\"\\s*,\\s*\"name\"\\s*:\\s*\"[^\"]*\"",
"color": "Green"
},
"tool-error": {
"pattern": "\"is_error\"\\s*:\\s*true",
"color": "Red",
"underline": true
},
"thinking-block": {
"pattern": "\"type\"\\s*:\\s*\"thinking\"",
"color": "Purple"
},
"interrupt": {
"pattern": "\\[Request interrupted by user[^\\]]*\\]",
"color": "Yellow"
}
}
}
}

Drop that in ~/.config/lnav/formats/claude-code/format.json and open a session:

Terminal window
lnav ~/.claude/projects/<project>/*.jsonl

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

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:

Terminal window
lnav -q -n \
-c ";SELECT type, count(*) n FROM claude_code_session GROUP BY type" \
-c ':write-table-to -' \
~/.claude/projects/<project>/*.jsonl

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

SELECT type, count(*) n,
round(100.0*count(*)/(SELECT count(*) FROM claude_code_session),1) pct
FROM claude_code_session GROUP BY type ORDER BY n DESC

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?

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

SELECT jget("message/content",'/0/type') block, count(*) n,
round(100.0*count(*)/(SELECT count(*) FROM claude_code_session
WHERE type='assistant'),1) pct
FROM claude_code_session WHERE type='assistant'
GROUP BY block ORDER BY n DESC

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.

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.

WITH tu AS MATERIALIZED (
SELECT jget("message/content",'/0/name') tool,
jget("message/content",'/0/id') tuid
FROM claude_code_session
WHERE type='assistant' AND jget("message/content",'/0/type')='tool_use'),
tr AS MATERIALIZED (
SELECT jget("message/content",'/0/tool_use_id') tuid,
CASE WHEN jget("message/content",'/0/is_error') IN (1,'true')
THEN 1 ELSE 0 END err
FROM claude_code_session
WHERE type='user' AND json_valid("message/content"))
SELECT tool, count(*) n, sum(err) errors,
round(100.0*sum(err)/count(*),1) err_pct
FROM tu JOIN tr USING (tuid)
GROUP BY tool HAVING n > 20 ORDER BY err_pct DESC

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.

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

SELECT "message/model" model, count(*) msgs,
sum("message/usage/output_tokens") out_tok,
round(100.0*sum("message/usage/cache_read_input_tokens")/
(sum("message/usage/cache_read_input_tokens")
+sum("message/usage/input_tokens")
+sum("message/usage/cache_creation_input_tokens")),1) cache_pct
FROM claude_code_session
WHERE type='assistant' AND "message/model" NOT LIKE '<%'
GROUP BY model ORDER BY out_tok DESC

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

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

WITH tu AS MATERIALIZED (
SELECT log_time lt, jget("message/content",'/0/name') tool,
jget("message/content",'/0/id') tuid,
coalesce(jget("message/content",'/0/input/command'),'') arg
FROM claude_code_session
WHERE type='assistant' AND jget("message/content",'/0/type')='tool_use'),
tr AS MATERIALIZED (
SELECT log_time lt, jget("message/content",'/0/tool_use_id') tuid
FROM claude_code_session
WHERE type='user' AND json_valid("message/content"))
SELECT tu.tool, round((julianday(tr.lt)-julianday(tu.lt))*86400.0,1) secs,
substr(tu.arg,1,28) arg
FROM tu JOIN tr USING (tuid)
ORDER BY secs DESC LIMIT 8

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.

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

SELECT regexp_match('^\s*(\w+)', jget("message/content",'/0/input/command')) cmd,
count(*) n
FROM claude_code_session
WHERE type='assistant' AND jget("message/content",'/0/name')='Bash'
GROUP BY cmd HAVING cmd NOT NULL ORDER BY n DESC LIMIT 10

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

SELECT coalesce(attributionAgent,'(main thread)') agent,
count(DISTINCT agentId) runs, count(*) msgs,
sum("message/usage/output_tokens") out_tok
FROM claude_code_session WHERE type='assistant'
GROUP BY agent ORDER BY msgs DESC

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:

-- which skills fire the most
SELECT coalesce(attributionSkill,'(none)') skill, count(*) n
FROM claude_code_session WHERE type='assistant'
GROUP BY skill ORDER BY n DESC LIMIT 8;
-- when do I work
SELECT strftime('%H:00', log_time) hour, count(*) msgs
FROM claude_code_session WHERE type IN ('user','assistant')
GROUP BY hour ORDER BY hour;
-- files I keep coming back to
SELECT jget("message/content",'/0/input/file_path') file, count(*) touches
FROM claude_code_session
WHERE type='assistant'
AND jget("message/content",'/0/name') IN ('Edit','Write','Read')
GROUP BY file HAVING file NOT NULL ORDER BY touches DESC LIMIT 10;
-- work spread across branches
SELECT gitBranch, count(DISTINCT sessionId) sessions, count(*) msgs
FROM claude_code_session WHERE gitBranch NOT NULL
GROUP BY gitBranch ORDER BY msgs DESC LIMIT 10;
-- slowest turns
SELECT log_time, round(durationMs/1000.0,1) secs, messageCount msgs, gitBranch
FROM claude_code_session WHERE subtype='turn_duration'
ORDER BY durationMs DESC LIMIT 8;
-- longest gaps, i.e. when I walked away
SELECT round(log_idle_msecs/3600000.0,1) idle_hours, log_time, type
FROM claude_code_session WHERE log_idle_msecs > 3600000
ORDER BY log_idle_msecs DESC LIMIT 5;

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

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.