JSON.stringify produces lone surrogates in API request body (400 invalid high surrogate)

Status Closed — not planned
Maintainer reply None cached
Activity 7 comments · opened Apr 6, 2026 · closed Jun 1, 2026

Bug

Claude Code occasionally sends API requests containing lone Unicode surrogates (U+D800–U+DFFF), causing the Anthropic API to reject them with:

API Error: 400 {"type":"error","error":{"type":"invalid_request_error",
"message":"The request body is not valid JSON: invalid high surrogate in string: line 1 column 411126 (char 411125)"}}

Root Cause

Node.js strings are internally UTF-16. When in-memory strings contain lone surrogates (from file reads, terminal output, or web content), JSON.stringify() serializes them as \uD8xx without a following low surrogate — which is invalid JSON per RFC 8259.

The stored JSONL files are clean (verified: no surrogates in the JSONL, subagent files, or tool-result cache). The lone surrogate is introduced transiently during request body construction in Node.js memory.

Common triggers on Windows

  • Emoji in code/comments read from files
  • Box-drawing characters from terminal/build output
  • Scraped web content with malformed UTF-16
  • Any Bash tool result containing non-BMP characters

Suggested Fix

Sanitize strings before JSON.stringify() when constructing the API request body. Node.js 20+ has a one-liner:

const safe = str.toWellFormed()  // replaces lone surrogates with U+FFFD

For older Node.js:

str.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/g, '\uFFFD')
   .replace(/(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '\uFFFD')

Impact

  • Session becomes stuck (the failed response is recorded, retrying may work but isn't guaranteed)
  • Loss of in-progress work context if user has to start a new session
  • More likely on Windows due to cp1252/UTF-16 encoding mismatches

Environment

  • Windows 11 Pro
  • Claude Code VS Code extension
  • Node.js (whatever version ships with Claude Code)
  • Python 3.14 confirmed the JSONL files themselves are valid UTF-8

Reproduction

Happens consistently when copying structured output (bullet points, chat bubbles, etc.) from another chat window into the Claude code VS extension chat field and sending it.

View original on GitHub ↗

7 Comments

github-actions[bot] · 6 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/16294
  2. https://github.com/anthropics/claude-code/issues/40610
  3. https://github.com/anthropics/claude-code/issues/40303

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

gregmarkowitz-gif · 6 months ago

Additional reproduction data: clean session with zero supplementary Unicode still fails

I'm hitting this consistently on Windows 11 Pro, Claude Code v2.1.94 (CLI), Opus 4.6.

Key finding: content-independent reproduction

I analyzed 4 bricked sessions from the same day. The critical one is a fresh session started after /clear that contained:

  • Zero supplementary Unicode characters in the entire JSONL
  • Zero supplementary Unicode characters in every file read during the session (Backlog.md, 4 handoff files, git diff output, a Python script output)
  • No WebFetch, no Gmail, no Playwright — only Read (9), Bash (12), Write (1), Edit (1)
  • No emoji anywhere in user messages or tool results

It still failed at column 110200:

API Error: 400 {"type":"error","error":{"type":"invalid_request_error",
"message":"The request body is not valid JSON: invalid high surrogate in string:
line 1 column 110200 (char 110199)"}}

This eliminates all content-based theories (emoji, web scraping, terminal escape sequences, file encoding issues). The JSONL on disk is verifiably clean — the corruption is purely transient during request body construction.

Likely trigger: large system prompt assembly

Column 110200 falls within the system prompt region of the request, not user content. My setup injects substantial context per request:

  • ~30 skills in the skill listing attachment
  • 8 hook configurations (PreToolUse, PostToolUse, SessionStart, PreCompact, Stop, UserPromptSubmit, SubagentStart, SubagentStop)
  • CLAUDE.md (~4KB) + companion metadata + deferred tools listing
  • Multiple system-reminder attachments per turn

The larger the assembled system prompt, the more buffer boundaries exist during string concatenation, increasing the probability of splitting a multi-byte UTF-8 sequence and producing a lone surrogate in the JS UTF-16 string.

Session cascade data

| Session | Start | Content | Error column | Trigger |
|---------|-------|---------|-------------|---------|
| 3e2de9c5 | 00:32 | 360 lines, 809KB, 12 WebFetch (had emoji) | 337749 | Normal facienda + research |
| 6bd978f4 | 01:27 | 296 lines, 598KB | (same) | Pasted error from above |
| 65852305 | 01:12 | 315 lines, 901KB | (same) | Pasted error from above |
| 40836368 | 09:29 | 79 lines, 230KB, zero emoji | 110200 | Fresh post-/clear facienda |

Environment

  • Windows 11 Pro 10.0.26200
  • Claude Code CLI v2.1.94
  • Console codepage: 437
  • Node.js (bundled with Claude Code)
  • All files verified UTF-8 clean (no lone surrogates on disk)

Suggested fix (echoing OP)

str.toWellFormed() (Node 20+) on all strings before JSON.stringify() in the API request path. This is the standard mitigation for transient lone surrogates in Node.js UTF-16 strings.

Alife-oprt · 6 months ago

Adding a macOS + VS Code extension data point for this issue.

### Environment

  • macOS Darwin 25.3.0
  • Claude Code VS Code extension (reinstalled, still fails)
  • Terminal claude CLI on the same machine: works normally
  • Model: Claude Opus 4.6 (1M context)

### Symptom
Every message via the VS Code extension fails with 400 invalid high
surrogate
, even on brand-new sessions with trivial input like 測試:

API Error: 400 {"type":"error","error":{"type":"invalid_request_error",
"message":"The request body is not valid JSON: invalid high surrogate in
string:
line 1 column 2979 (char 2978)"},"request_id":"req_011Ca224F35QVUmZf322vT1e"}

Other request IDs observed: req_011Ca21bQMQpTCwQXyTJ6ced,
req_011Ca21s1mJdKR7nuuFnghcg.

### Confirming content-independence (matches @gregmarkowitz-gif's finding)

  • Scanned all recent session .jsonl files under ~/.claude/projects/ —

zero lone UTF-16 surrogates (raw or escaped)

  • Scanned all user-configured hook outputs (SessionStart / PreToolUse / Stop)

— all valid UTF-8, no lone surrogates

  • Happens on fresh sessions, no --continue / --resume

### Why likely system-prompt-size related
My VS Code environment loads a substantial assembled system prompt per
request:

  • Vercel plugin injecting ~50+ skills list + per-prompt skill

auto-suggestions (hooks fire on every UserPromptSubmit)

  • Multiple MCP servers (Notion, Gmail, Slack, Google Calendar) exposing many

deferred tools

  • Auto-memory system (~/.claude/projects/*/memory/MEMORY.md) loaded every

turn

  • Global CLAUDE.md (~2KB)
  • Several user hooks (SessionStart, PreToolUse, Stop)

The terminal CLI on the same machine, same account, same settings works —
which fits the theory that the bug triggers above some assembled-prompt size
threshold, and the VS Code extension packages more context than the CLI.

### Reproduction
Trivially reproducible — any message in the VS Code extension fails. Disabling
the Vercel plugin alone did not resolve it on my end.

### Request
Please prioritize the str.toWellFormed() fix suggested by OP — this is
blocking VS Code extension usage entirely for users with heavier plugin/MCP
setups, even though the CLI remains usable.

👎'd the duplicate-close bot to keep this open.

github-actions[bot] · 4 months ago

Closing for now — inactive for too long. Please open a new issue if this is still relevant.

wilhelmsson424-jpg · 4 months ago

Still reproducing on v2.1.x. Adding a concrete, deterministic repro path that may help, since the existing reports mostly describe paste/terminal sources.

Trigger: byte-boundary truncation in a SessionStart hook.

A SessionStart hook that injects file content into context used head -c 1500 "$file". head -c cuts on a byte boundary, not a character boundary. When the cut lands in the middle of a multi-byte UTF-8 character (e.g. ✅ = E2 9C 85), only the leading byte(s) survive. That partial byte sequence ends up as a lone surrogate (\udce2 in our case), which is injected into context every session, persisted into the .jsonl transcript, and then re-serialized into the request body when a new window loads that transcript — producing:

API Error: 400 The request body is not valid JSON: invalid high surrogate in string: line 1 column 134401 (char 134400)

Isolated proof: byte-cut at offset 15 of "...emoji ✅..." yields trailing bytes 20 e2 → exactly the \udce2 we found in 7 stored transcripts.

Two observations:

  1. App side (this issue): .toWellFormed() before JSON.stringify() of the request body would make this class of corruption non-fatal regardless of source (paste, terminal, web, hooks, tool output). The session becomes permanently un-openable otherwise, since the bad surrogate is now baked into the saved transcript.
  1. For anyone hitting this from their own hooks/scripts: never head -c / byte-slice text destined for context. Truncate on character boundaries (e.g. decode UTF-8 with errors='ignore' then slice by characters). We also had to sanitize already-corrupted transcripts: parse each JSONL line, strip lone surrogates from the parsed string values (keeping valid pairs), and write back with json.dumps(..., ensure_ascii=False).

Given the saved-transcript persistence makes a session unrecoverable, this seems worth reopening — sanitizing at serialization time would prevent the unrecoverable state for all of the trigger sources listed above.

kagimalee-cmd · 3 months ago

The existing reports here trace this to bash tool output, binary file reads, or plugin content with emoji — all external sources of lone surrogates. I found a different trigger path: the model's own generated text inside a tool_use input can itself contain a lone surrogate.

Concretely, in a session that started hitting no low surrogate in string on every subsequent turn, I traced the corrupted character back to an AskUserQuestion tool_use call where one of the option descriptions (Korean text) contained \ud831 with no matching low surrogate — sitting in place of what should have been a normal character. Several nearby option labels in the same session also showed similar-but-wrong Hangul syllables, suggesting some kind of generation-time corruption rather than a truncation/copy-paste source.

This path isn't covered by the v2.1.166 fix (which scoped to MCP server descriptions and classifier side-queries), since the corrupted text originates from the model's own tool-call arguments rather than an external string being embedded. Once it lands in the session transcript, every following turn re-sends it and fails the same way — only a fresh session recovers. Wanted to flag this as a distinct repro path in case it's useful for scoping a more general fix (sanitizing any string before JSON.stringify(), not just the two patched call sites).

github-actions[bot] · 1 month ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.