From claude-code-session-repair
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.
How this skill is triggered — by the user, by Claude, or both
Slash command
/claude-code-session-repair:claude-code-session-jsonl-whitespace-text-blockThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
A Claude Code session becomes permanently stuck: every API request — typing a
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.
Any one of these:
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.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).
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.
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
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.
Idempotent script in scripts/fix-empty-text-blocks.py (also installable to
~/.claude/bin/). It:
message.content array"." (the smallest
non-whitespace placeholder — preserves message-shape alternation; deleting
the block could orphan adjacent tool_use_ids)<path>.bak.<epoch> before mutating~/.claude/bin/fix-empty-text-blocks.py PATH/TO/session.jsonl
~/.claude/bin/fix-empty-text-blocks.py --all # scan every session file
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.
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.)
$ /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.
"." 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.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 skillunexpected tool_use_id found in advisor_tool_result blocks → that skillStop 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.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.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.Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub wan-huiyan/claude-code-session-repair --plugin claude-code-session-repair