| name | claude-code-session-jsonl-whitespace-text-block |
| description | Repair a Claude Code session whose transcript JSONL has assistant messages
containing a whitespace-only text block (typically `{"type":"text","text":" "}`),
causing every API call (including new prompts, `/compact`, and `claude --resume`)
to fail with HTTP 400 "messages: text content blocks must contain non-whitespace
text". Use when: (1) a session refuses to accept new input with that exact 400,
(2) `/compact` fails with the same 400 (since compact replays the full history
first — it does NOT bypass the validator), (3) `claude --resume <id>` immediately
exits with the same error. Covers diagnosis via JSON-walking the message.content
array (NOT plain grep — the empty block is often nested), the distinction between
message.content (sent to API → must fix) and toolUseResult (local metadata →
harmless, leave alone), the underlying cause (extended-thinking turn serialization
emits a synthetic single-space placeholder before the thinking block), and an
idempotent fixer script that replaces " " with "." while preserving message shape.
|
| author | Claude Code |
| version | 1.0.0 |
| date | "2026-05-28T00:00:00.000Z" |
Claude Code session JSONL — whitespace-only text block repair
Problem
A Claude Code session becomes permanently stuck: every API request — typing a
new prompt, /compact, or claude --resume <id> — returns:
API Error: 400 messages: text content blocks must contain non-whitespace text
The Anthropic API validates every {"type":"text","text":...} block in
incoming messages and rejects any whose text is empty or whitespace-only. The
broken block lives in the on-disk transcript, so it gets replayed every time —
the session is hostage until the file is edited.
Context / Trigger Conditions
Any one of these:
- Active session refuses new input with
400 ... text content blocks must contain non-whitespace text.
/compact fails with the same 400. Important: /compact does NOT bypass the validator — it sends the full history to the model first, so the same broken block kills it before any compaction happens.
claude --resume <session-id> exits with the same 400 on first turn.
- The session has used extended thinking at least once (this is the dominant trigger — see Root Cause).
Root Cause
When an assistant turn starts with an extended-thinking block, Claude Code's
serializer emits a synthetic leading text block whose content is a single
space " " before the thinking block, presumably to satisfy some internal
content-ordering constraint. Each content block is stored as its own JSONL
line, so you end up with assistant lines like:
{"message":{"role":"assistant","content":[{"type":"text","text":" "}]}, ...}
The Anthropic API rejects these on replay. A long extended-thinking session can
silently accumulate dozens of these — one user's session had 88 in a single
file, another had 20 — and they only surface when something replays the full
history (new prompt, /compact, resume).
Solution
Step 1 — locate candidate JSONLs
Session JSONLs live at:
~/.claude/projects/<encoded-cwd>/<session-uuid>.jsonl
The encoded-cwd directory replaces / with - in the working directory path.
The most recently modified .jsonl in the relevant project dir is usually the
broken one.
Step 2 — confirm the diagnosis (walk JSON, do NOT just grep)
The empty block is typically deeply nested inside message.content[0].text.
Plain grep '"text":""' works for exactly empty strings but misses
" "/"\n"/etc. Use a recursive JSON walk:
python3 - <<'PY'
import json, glob, pathlib
home = pathlib.Path.home()
def w(o):
if isinstance(o, dict):
if o.get("type") == "text":
t = o.get("text")
if isinstance(t, str) and t.strip() == "":
yield 1
for v in o.values(): yield from w(v)
elif isinstance(o, list):
for v in o: yield from w(v)
hits = []
for fp in sorted(glob.glob(str(home / ".claude/projects/*/*.jsonl"))):
n = 0
with open(fp, errors='ignore') as f:
for line in f:
line = line.strip()
if not line: continue
try:
n += sum(w(json.loads(line)))
except: pass
if n: hits.append((n, fp))
for n, fp in hits: print(f"{n:5d} {fp}")
PY
Step 3 — distinguish API-bound vs local-only empty blocks
Not every whitespace text block causes the 400. Two locations matter:
| Path in JSONL | Sent to API? | Action |
|---|
.message.content[*].text | Yes | Must fix |
.toolUseResult (and nested) | No — local | Leave alone |
A fixer that targets only the API-bound location avoids unnecessary mutation.
Step 4 — run the fixer
Idempotent script in scripts/fix-empty-text-blocks.py (also installable to
~/.claude/bin/). It:
- Walks each line's
message.content array
- Replaces any whitespace-only text block's text with
"." (the smallest
non-whitespace placeholder — preserves message-shape alternation; deleting
the block could orphan adjacent tool_use_ids)
- Writes a backup at
<path>.bak.<epoch> before mutating
- Skips files with no fixes (no backup created)
~/.claude/bin/fix-empty-text-blocks.py PATH/TO/session.jsonl
~/.claude/bin/fix-empty-text-blocks.py --all
Step 5 — re-launch and verify
The on-disk file is now valid. Quit the broken Claude Code session (Ctrl+D /
close terminal) and re-launch:
claude --resume <session-id>
The first turn will succeed. /compact will now work.
Verification
After fixing, re-run the Step-2 detector. Files with remaining empty text blocks in message.content should be 0. (Stragglers in toolUseResult are
benign — they don't cross the API boundary.)
Example
$ /Users/x/.claude/bin/fix-empty-text-blocks.py --all
/Users/x/.claude/projects/.../<uuid-A>.jsonl
fixed 20 block(s); backup at .../<uuid-A>.jsonl.bak.1779986308
/Users/x/.claude/projects/.../<uuid-B>.jsonl
fixed 88 block(s); backup at .../<uuid-B>.jsonl.bak.1779986328
Total fixes across 1326 file(s): 108
The two affected sessions resumed cleanly; /compact ran end-to-end on both.
Notes
- Why replace with
"." instead of deleting the block? Deleting an entire
content block can violate alternation invariants if the block is the only
one in a content array (it was, in observed cases). Replacement keeps the
message structurally identical and the placeholder character is
inconsequential to model behaviour on replay.
- Why does this differ from
claude-code-session-jsonl-orphan-advisor-tool-result?
Same family of failure (broken JSONL → all API calls fail → /compact
blocked) but the offending block type and root cause are different. That
skill repairs orphan server_tool_use / *_tool_result pairs caused by
mid-stream interruption. This skill repairs whitespace-only text blocks
caused by extended-thinking serialization. The diagnosis path branches on
the exact 400 message:
text content blocks must contain non-whitespace text → this skill
unexpected tool_use_id found in advisor_tool_result blocks → that skill
- Optional auto-scrub via Stop hook: writing a global
Stop hook in
~/.claude/settings.json that calls the fixer with the active session's
JSONL path on every session end will prevent recurrence cost-free. Not
enabled by default because it mutates session state on every stop;
recommend only after the user understands the trade-off.
- The
0 MB free red herring: an unrelated symptom that can appear on the
same broken session is the temp filesystem at /private/tmp/claude-<uid>/.../tasks is full (0MB free). This is Claude Code's per-session tasks-dir quota,
not actual disk pressure (df -h will show plenty of room). Clearing
/private/tmp/claude-<uid>/* or starting a new session resets the quota.
Do not assume disk is full when you see this — verify with df -h first.
See also
claude-code-session-jsonl-orphan-advisor-tool-result — sibling skill for
the orphan tool_use_id variant of the broken-JSONL failure family.
claude-code-session-jsonl family more broadly — the JSONL on disk is the
single source of truth for replay; any structural defect in it is hostage
until repaired in place.
References
- Anthropic Messages API content-block validation: empty / whitespace-only
text blocks are rejected with HTTP 400 at validation time, before the
request reaches the model.
- Observed on Claude Code (macOS) — Opus 4.7, May 2026. Reproduced across two
user-active sessions, 108 total whitespace blocks repaired without loss of
conversation state.