Gotchas
Non-obvious behaviors in the stream-json protocol that will bite you if you don't know about them.
Double-Encoded result Field
The result field in result and system/result events is a JSON string inside JSON. It's not a plain string — it's a string that has been JSON.stringify()'d an extra time.
What It Looks Like
{
"type": "result",
"result": "\"Here is the summary of changes...\""
}Note the escaped quotes inside the string value.
How to Parse
const raw = event.result // '"Here is the summary of changes..."'
const actual = JSON.parse(raw) // 'Here is the summary of changes...'Why It Happens
Claude Code internally JSON.stringify()s the result text, then the NDJSON serialization JSON.stringify()s the entire event object — double-encoding the string.
TIP
The Translator handles this automatically. You only need to worry about this if you're working with raw ClaudeEvent objects.
Polymorphic tool_result.content
The content field in tool_result blocks has three possible shapes:
Shape 1: Plain String
{ "type": "tool_result", "tool_use_id": "toolu_1", "content": "file contents here" }Shape 2: Array of Text Blocks
{
"type": "tool_result",
"tool_use_id": "toolu_1",
"content": [
{ "type": "text", "text": "File edited successfully" }
]
}Shape 3: Null
{ "type": "tool_result", "tool_use_id": "toolu_1", "content": null }Handling All Three
Use extractContent():
import { extractContent } from 'claude-code-parser'
extractContent("hello") // → "hello"
extractContent([{ type: "text", text: "hello" }]) // → "hello"
extractContent(null) // → ""Or if you're using the Translator, it normalizes this automatically — ToolResultEvent.output is always a plain string.
Thinking Block Field Name
The thinking content block uses different field names depending on the Claude Code version:
// Version A: uses "thinking" field
{ "type": "thinking", "thinking": "Let me analyze..." }
// Version B: uses "text" field
{ "type": "thinking", "text": "Let me analyze..." }Always check both:
const text = block.thinking ?? block.text ?? ''The Translator handles this automatically.
system/result vs result
Both of these can appear as turn-completion events:
// Legacy path
{ "type": "system", "subtype": "result", "session_id": "abc" }
// Current path
{ "type": "result", "subtype": "success", "session_id": "abc", "total_cost_usd": 0.02 }The top-level result event has more fields (cost, usage, duration). The system/result path is legacy but still appears in some versions.
The Translator handles both and emits turn_complete or error for either.
Token Math
The modelUsage object in result events breaks tokens into four categories:
| Field | What It Is |
|---|---|
inputTokens | Fresh tokens sent to the model |
cacheReadInputTokens | Tokens served from Anthropic's prompt cache |
cacheCreationInputTokens | Tokens written to prompt cache (counts as input) |
outputTokens | Tokens generated by the model |
Total input tokens used for billing:
total = inputTokens + cacheReadInputTokens + cacheCreationInputTokensThe Translator computes this automatically in TurnCompleteEvent.inputTokens.
Cache tokens are cheaper
cacheReadInputTokens are billed at a lower rate than inputTokens. The total_cost_usd field already accounts for this. If you're displaying token counts, you may want to break them out separately.
Empty assistant Events
Claude Code may emit assistant events with empty content arrays:
{ "type": "assistant", "message": { "content": [] } }Or with a null/missing message field:
{ "type": "assistant" }Both are valid and should be silently skipped. The Translator handles this.
stop_reason Values
The stop_reason field in assistant messages indicates why Claude stopped generating:
| Value | Meaning |
|---|---|
end_turn | Claude finished its response naturally |
tool_use | Claude wants to call a tool (waiting for approval/result) |
max_tokens | Hit the output token limit |
This field is informational — the Translator doesn't use it, but it's available on the raw ClaudeMessage if you need it.
Rate Limit Events
Claude Code may emit rate_limit_event objects on stdout:
{ "type": "rate_limit_event", ... }These are silently skipped by the Translator. If you need to handle rate limiting, check for this event type in raw ClaudeEvent objects before passing them to the translator.