Skip to content
gentic.news — AI News Intelligence Platform
Connecting to the Living Graph…

Listen to today's AI briefing

Daily podcast — 5 min, AI-narrated summary of top stories

A developer's terminal window displaying JSONL log files from Claude Code CLI, with code snippets highlighting three…
Open SourceScore: 90

Build a Bulletproof Claude Code JSONL Parser

Learn 3 battle-tested patterns for parsing Claude Code's JSONL session files under ~/.claude/projects/, including tolerant whitelists, versioned derived data, and toWellFormed() string normalization.

·14h ago·4 min read··17 views·AI-Generated·Report error
Share:
Source: dev.tovia devto_claudecode, reddit_claudeMulti-Source
How do I parse Claude Code's JSONL files reliably when the schema changes daily?

Parse Claude Code's JSONL with a tolerant whitelist of known types, preserve unknown shapes as raw JSON, version your derived data, and normalize strings with toWellFormed() to handle lone UTF-16 surrogates.

TL;DR

Your Claude Code sessions are stored as JSONL under ~/.claude/projects/ — here's how to parse them without breaking when the schema changes.

Build a Bulletproof Claude Code JSONL Parser: 3 Patterns That Survived a Dozen CLI Releases

Every conversation you have with Claude Code is written to disk as JSONL, under ~/.claude/projects/. Your decisions, your dead ends, the bug hunt that took three sessions: it is all there. You have probably never opened one.

The catch: the format is an internal implementation detail. No documentation, no version field, no stability guarantee. The schema changes whenever the CLI updates, which is, at the current pace, almost daily. But with the right patterns, you can build tools that survive—and learn from—those changes.

Here are three patterns from a developer who built a read-only replay and search tool on top of these files and kept it alive through a dozen CLI releases.

Pattern 1: Tolerant Line Parsing with an Explicit Whitelist

The naive loop (JSON.parse each line, switch on type) works on day one. The question is what happens when a CLI update introduces a type nobody has seen before. This is not hypothetical.

The approach that holds up: keep an explicit whitelist of known types, and treat everything outside it as "parse failed, but preserved":

const KNOWN_MESSAGE_TYPES = new Set([
  'user', 'assistant', 'system',
  'queue-operation', 'last-prompt',
  'progress', 'attachment', 'file-history-snapshot',
  'permission-mode', 'custom-title', 'ai-title',
  'agent-name', 'pr-link',
])

function parseLine(line: string): ParsedLine | null {
  if (!line.trim()) return null

  let obj: Record<string, unknown>
  try {
    obj = JSON.parse(line)
  } catch {
    return null // malformed line: skip, never throw
  }

  const type = typeof obj.type === 'string' ? obj.type : 'unknown'
  const parseFailed = !KNOWN_MESSAGE_TYPES.has(type)
  // unknown type → keep the raw JSON string for later re-parse
  // known type → extracted fields are enough, raw can be dropped
  ...
}

The whitelist does double duty as a storage policy. For known types, the extracted columns are sufficient and the raw JSON can be discarded; that alone reclaims most of the disk space. For unknown types, the raw line goes into an archive table untouched. When a future version of the parser learns the new shape, the evidence is still there.

One more detail that pays off: cap the length of identifier fields (uuid, requestId) at something sane like 128 chars before trusting them.

Pattern 2: Version Your Derived Data, Not Just Your Schema

Preserving unknowns only matters if you can act on them later. The mechanism is a SUMMARY_VERSION integer stored per session. When the parser learns new tricks, bump the version; the indexer sees stale versions and re-parses those sessions automatically on the next sync.

This turns "the schema changed again" from a migration crisis into a routine: extend the parser, bump the version, let the backfill run. No manual steps, no data loss, no "please delete your index and start over" release notes.

War Story 1: The Lone Surrogates

One day the indexer started producing strings that crashed downstream consumers. The cause: some JSONL lines contained unpaired UTF-16 surrogates. Half an emoji, lurking in a tool-error message.

How does half an emoji end up on disk? Older Claude Code versions (up to around 2.1.132) truncated long tool outputs by byte length, and the cut sometimes landed mid-emoji. JSON.stringify happily writes the lone surrogate as a \udXXX escape, the file looks like clean ASCII, and JSON.parse faithfully reconstructs the broken string at read time.

The fix is one line, if it lands in the right place:

// at the parser's exit boundary, applied to every extracted string
export function ensureWellFormed(s: string): string {
  return s.toWellFormed() // lone surrogates → U+FFFD
}

The placement is the actual lesson. Normalize once, at ingestion, and every consumer downstream (search index, renderer, Markdown export) gets to assume well-formed strings forever. (String.prototype.toWellFormed() needs Node.js 20+.)

War Story 2: The Tokens That Counted Themselves Twice

The tool's token dashboard once reported usage numbers roughly 2.3× higher than reality. The cause: one API response can become several JSONL lines. When a response contains multiple content blocks (text plus tool calls), Claude Code writes one assistant entry per block, and each entry carries a copy of the full token count. If you naively sum token counts per line, you overcount by the number of blocks.

The fix: only count tokens from the first assistant line in a response, or better, deduplicate by requestId before summing.

Try It Now

  1. Explore your own sessions: ls ~/.claude/projects/ to see what's there. head -n 5 ~/.claude/projects/<project>/<session>.jsonl to peek at the format.
  2. Build a tolerant parser: Start with the whitelist pattern above. Don't assume stability—plan for unknown types.
  3. Add string normalization: Apply toWellFormed() at the ingestion boundary to avoid downstream crashes.
  4. Version your derived data: Store a version number per session so you can re-parse when your parser improves.

Source: dev.to

Sources cited in this article

  1. Claude Code
Source: gentic.news · · author= · citation.json

AI-assisted reporting. Generated by gentic.news from 1 verified source, fact-checked against the Living Graph of 4,300+ entities. Edited by Ala SMITH.

Following this story?

Get a weekly digest with AI predictions, trends, and analysis — free.

AI Analysis

**What should Claude Code users DO differently because of this?** First, if you're building any tool that reads Claude Code's JSONL session files (for analytics, search, replay, or debugging), stop assuming the schema is stable. Implement a tolerant parser with an explicit whitelist of known message types, and preserve unknown lines as raw JSON. This pattern, demonstrated in the source, will keep your tool working through the frequent CLI updates that Anthropic ships. Second, add `toWellFormed()` string normalization at your ingestion boundary. The lone surrogate bug is a real threat—older Claude Code versions (up to ~2.1.132) could truncate mid-emoji, corrupting strings. Apply this normalization once, at parse time, and your downstream consumers will never see malformed strings. This is especially critical if you're piping data into SQLite, an IPC bridge, or any strict text consumer. Third, version your derived data. Store a `SUMMARY_VERSION` integer per session so that when you extend your parser to handle new message types, you can automatically re-parse stale sessions. This turns schema migrations from manual crises into routine backfills. The source author's experience shows this is essential for keeping a tool alive through a dozen CLI releases.

Mentioned in this article

Enjoyed this article?
Share:

AI Toolslive

Five one-click lenses on this article. Cached for 24h.

Pick a tool above to generate an instant lens on this article.

Related Articles

From the lab

The framework underneath this story

Every article on this site sits on top of one engine and one framework — both built by the lab.

More in Open Source

View all