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

# 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:

1. Active session refuses new input with `400 ... text content blocks must contain non-whitespace text`.
2. `/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.
3. `claude --resume <session-id>` exits with the same 400 on first turn.
4. 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:

```json
{"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:

```python
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)

```bash
~/.claude/bin/fix-empty-text-blocks.py PATH/TO/session.jsonl
~/.claude/bin/fix-empty-text-blocks.py --all   # scan every session file
```

### 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:

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