[BUG] API Error 400 "no low surrogate in string" when Bash output contains invalid Unicode
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
When running commands that produce output with invalid Unicode characters (specifically unpaired surrogate pairs), Claude Code crashes with an API error. The Bash tool successfully captures the output, but when the next API request is serialized to JSON, the invalid Unicode causes JSON encoding to fail.
The error occurs because JSON requires valid Unicode, and unpaired surrogates (U+D800-U+DBFF without corresponding U+DC00-U+DFFF) are not valid.
This commonly happens with:
- Mutation testing tools (mutmut, Stryker) that produce progress bars/terminal graphics
- Tools that emit partial terminal escape sequences
- Commands that capture binary data or corrupted text
What Should Happen?
Claude Code should sanitize Bash output to ensure valid Unicode before:
- Adding the output to the conversation context, OR
- Serializing the API request payload
Invalid Unicode characters should be replaced with the Unicode replacement character (U+FFFD) or removed entirely. This is standard practice for handling untrusted text input.
Error Messages/Logs
API Error: 400 {"type":"error","error":{"type":"invalid_request_error","message":"The request body is not valid JSON: no low surrogate in string: line 1 column 280696 (char 280695)"},"request_id":"req_011CWo9ijhAZvXz786YjeMai"}
The error position (280696) indicates the invalid character is somewhere in the full request body, not necessarily at the end of the Bash output.
Steps to Reproduce
- Run a command that produces output with terminal control sequences or binary data:
``bash``
source .venv/bin/activate && mutmut run --max-children 4 2>&1 | tail -30
- The Bash command completes successfully, producing output (in my case: 30,029 characters / ~14,608 tokens)
- On the next API call, Claude Code crashes with the "no low surrogate in string" JSON error
Why mutmut triggers this:
- mutmut uses terminal escape sequences for progress indicators
- Some sequences may be partially captured or corrupted
- The
| tail -30captures output that may include incomplete escape sequences
Minimal reproduction (if you can create invalid Unicode):
# This would need a command that outputs actual invalid Unicode
# The key is any output containing bytes like 0xED 0xA0 0x80 (unpaired high surrogate)
Claude Model
None
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.0.76
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Terminal.app (macOS)
Additional Information
Root Cause Analysis
The issue is that Bash tool output is not sanitized for valid Unicode before being included in the API request. While the Bash tool has a 30KB size limit (BASH_MAX_OUTPUT_LENGTH), there's no character validity check.
Suggested Fix
Add Unicode sanitization in one of these locations:
- Bash tool result processing (before adding to context):
``javascript``
function sanitizeUnicode(text) {
// Replace unpaired surrogates with replacement character
return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '\uFFFD');
}
- API request serialization (global safety net):
- Sanitize the entire request body before JSON.stringify()
- This would catch issues from any source, not just Bash
Workaround
Users can work around this by using hooks to sanitize Bash output (PostToolUse on Bash), but this should be handled by Claude Code itself.
Related Context
- The Bash tool already has size limiting (30KB)
- MCP tools have token limiting (25K tokens)
- Task/TaskOutput has no built-in limiting (addressed by user hooks)
- None of these tools sanitize for valid Unicode
18 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
Follow-up: Hook-based workaround is not possible
I attempted to implement a workaround using Claude Code hooks, but discovered an architectural limitation that makes this impossible to fix at the hook level.
What I Tried
Added a
sanitize_unicode()function to my PostToolUse hook (output_validator.py) that:text.encode("utf-8")errors="replace"Why It Doesn't Work
PostToolUse hooks cannot modify
tool_result.| Hook Type | Available Output Fields | Can Modify Output? |
|-----------|------------------------|-------------------|
| PreToolUse |
updatedInput,permissionDecision| Yes (command input) || PostToolUse |
additionalContext,systemMessage| No (read-only) |The hook schema explicitly defines:
updatedInputto modify the command before executionadditionalContextto add commentary—noupdatedResultequivalentMy sanitization runs, but the original corrupted
tool_resultis still serialized and sent to the API. The hook's sanitized copy is discarded.Confirmed Root Cause Location
The sanitization must happen in Claude Code core, specifically:
There is no hook point between these two steps that allows modification.
Recommendation
Add Unicode sanitization in the Bash tool's output processing, similar to how the tool already has size limiting (30KB
BASH_MAX_OUTPUT_LENGTH):This would catch invalid Unicode from any source (mutmut, binary data, corrupted files) before it can break JSON serialization.
GitHub Issue #16294 Follow-up: Workaround Implemented
Issue: Invalid Unicode in Bash Output Causes API Errors
Status: Workaround implemented until Anthropic fixes the core issue
---
The Problem
Running
mutmut(mutation testing tool) via Claude Code crashes with:Root cause: mutmut's terminal output contains invalid Unicode characters (unpaired UTF-16 surrogates from ANSI escape sequences and progress bars). When Claude Code serializes the bash output to JSON for the API, these invalid surrogates cause JSON encoding to fail.
Why hooks don't help: PostToolUse hooks can only add
additionalContext—they cannot modify the originaltool_result. The corrupted output is still serialized and sent to the API.---
The Workaround
Created
scripts/mutmut_safe.sh— a wrapper script that intercepts mutmut output before Claude sees it.How It Works
var/logs/mutmut-output.log)\x1b[...mpatterns)Files
| File | Purpose |
|------|---------|
|
scripts/mutmut_safe.sh| Wrapper script ||
var/logs/mutmut-output.log| Raw output (may contain invalid chars) ||
var/logs/mutmut-safe.log| Sanitized output (safe for Claude) |Usage
---
Why This Works
The key insight is that Claude Code's crash happens during JSON serialization of tool output, not during command execution. By ensuring only valid UTF-8 reaches stdout, we prevent the serialization failure entirely.
The wrapper acts as a "Unicode firewall" between mutmut and Claude Code.
---
Proper Fix (for Anthropic)
The correct solution is for Claude Code to sanitize bash output before JSON serialization, similar to how it already handles output size limits (30KB
BASH_MAX_OUTPUT_LENGTH). A simple regex replacement of unpaired surrogates with the Unicode replacement character (U+FFFD) would prevent this class of errors entirely.Until that fix ships, this wrapper provides a reliable workaround for mutation testing workflows.
Additional data point and workaround
We encountered this same error while using Claude Code programmatically via
@link-assistant/hive-mind(an AI issue solver that orchestrates Claude Code sessions).Our case
claudeCLI)claude-opus-4-5-20251101"no low surrogate in string: line 1 column 160309 (char 160308)"Root cause analysis
The issue is that
JSON.stringify()in JavaScript does NOT reject lone surrogates — it produces technically invalid JSON that JavaScript's ownJSON.parse()accepts (lenient parser), but Anthropic's server-side parser (likely Rust/Python) correctly rejects per RFC 8259 Section 8.1.Common sources of lone surrogates:
.affiles, etc.)Suggested fix
Add a surrogate sanitization step in the tool result processing pipeline, before the result is added to the conversation history. Here's a minimal implementation:
This regex:
Our workaround
We implemented this sanitization in our wrapper (
hive-mind) at two points:safeJsonStringify()in our interactive mode sanitizes string values during JSON serializationThis doesn't fix the core issue (tool output corruption within Claude Code's internal conversation history), but it prevents the prompts we control from triggering the error.
Detailed case study: https://github.com/link-assistant/hive-mind/blob/main/docs/case-studies/issue-1204/README.md
Related issue: https://github.com/link-assistant/hive-mind/issues/1204
Can confirm this is still an issue - especially when using Playwright MCP to get console logs and snapshots. Might try to use this sanitize function. Or just strip emojis out of all our console logs. Who knew that emojis are Human-readable but Not Claude-friendly. 🤷🏻♂️
Additional data point:
<persisted-output>truncation creates orphaned surrogatesWe encountered this error in a slightly different scenario than Bash output - the truncation feature itself is a source of the problem.
Our case
claude-opus-4-5-20251101<persisted-output>truncation to ~2KB previewRoot cause identified
The truncation in
<persisted-output>is byte/character-based, not Unicode-aware. When the truncation point lands in the middle of a UTF-16 surrogate pair (which emojis require), it leaves an orphaned high surrogate like\ud83e.Evidence from the log file (position ~98553):
The
\ud83eis the orphaned high surrogate from what was originally likely🤖(U+1F916 =\ud83e\udd16).Impact
This makes the error more unpredictable than terminal escape sequences - any emoji-containing GitHub comment, PR description, or issue body can trigger it when the output happens to exceed the truncation threshold and the cut point lands on an emoji.
Suggested fix
The truncation logic needs to be Unicode-aware:
Or apply the surrogate sanitization after truncation but before JSON serialization.
Related case study
Full analysis: link-assistant/hive-mind case study
Additional reproduction: Windows + MCP tool output + context compaction
Environment
Reproduction steps
Exploresubagent (29 tool uses, ~83K tokens output)browser_navigate→browser_network_requests→browser_console_messages)API Error: 400 "no low surrogate in string: line 1 column 190443"Key observation: Not caused by file content
I scanned all 2,889 source files + 331 config/memory/skill/command/agent files for lone surrogates — zero issues found. All files pass
JSON.stringify()→JSON.parse()round-trip.This strongly suggests the surrogate split occurs during Claude Code's internal context assembly or compaction, not from file content. Specifically:
Additional finding: MEMORY.md auto-load file
In a separate instance, a broken emoji (
📁stored as lone surrogate 0xD83D without 0xDCC1) in an auto-loadedMEMORY.mdfile caused the same error on every session start. This was fixed by replacing the emoji with plain text. However, it's unclear how the emoji became corrupted in the first place — it may have been corrupted during a previous context compaction/write cycle.Suggested fix
Additional failure mode: the bug can also break compaction, not just the immediately following API call.
In our case, a GitHub CI API response contained a job name with an emoji truncated mid-surrogate pair (
\ud83ewithout its following\ude73). This got stored in the conversation JSONL. The session continued working for a while — the invalid Unicode was dormant in the history. Later, a normal prompt failed with the surrogate error. Attempting manual compaction to recover also failed with the same error:This makes the bug harder to diagnose: the failure doesn't necessarily happen on the tool call that introduced the bad Unicode — it can surface much later, on an unrelated prompt, once the conversation grows large enough that the offending content appears in the request body. By then the connection to the original tool output is not obvious.
Workaround for permanently broken sessions:
If your session is stuck in a compaction loop, you can recover it by editing the JSONL file directly:
~/.claude/projects/<project-slug>/<session-uuid>.jsonlcp session.jsonl session.jsonl.bakAdditional reproduction case:
mutmuton LinuxSame issue. Running a mutmut script via Bash tool that outputs per-mutant status lines (100+ lines with killed/survived/timeout results). Session becomes permanently stuck after the output is appended to context.
Context:
Bash(sleep 100 && ps -p 306996 -o etime --no-headers 2>/dev/null || echo "finished" && tail -3 /tmp/mutmut_rerun.log)no low surrogate in string: line 1 column 269049 (char 269048)mutmutoutput likely contains ANSI escape sequences, raw diff fragments, and progress bar charactersNo recovery possible —
/compact, new input, Ctrl+C all produce the same 400 error. Only fix is killing the session (losing 120+ min of context).Would really benefit from output sanitization before appending to conversation history.
Still unresolved
Bumping this — still hitting the 400 error when Bash output contains invalid Unicode surrogates. The fix (Unicode sanitization) was described in the issue, would be great to see it addressed.
<img width="895" height="155" alt="Image" src="https://github.com/user-attachments/assets/79c8254e-ca4d-4c43-8e40-57a54a8762be" />
Additional failure mode: desktop app's Code tab — every request fails at fixed position, regardless of input
Adding a new data point that significantly widens the scope of this bug. In the macOS Claude desktop app (Code tab), every request to the model 400s with invalid high surrogate in string at a fixed position, even on a brand-new session with the most
trivial possible prompt. The desktop Code surface is effectively non-functional for me on macOS.
Environment
Reproduction
Every request returns:
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 1511 (char 1510)"}}
Evidence the bug is in fixed app-injected content (not user data)
The error position is identical across every request:
I scanned ~/.claude/ (agents/skills/memory/settings/hooks) for non-BMP characters; the only astral chars were five 🟢 Live status lines in agent files. I replaced them with [LIVE] to eliminate them. The error position did not move by a single char.
That rules out user config as the source — the bad character is in something the desktop app injects into the request body before any user content.
This is consistent with @konard's earlier observation that <persisted-output> truncation can produce orphaned surrogates: the fact that char 1510 stays nailed across totally unrelated inputs strongly suggests a fixed-length truncation in the desktop
app's request prefix landing inside a UTF-16 surrogate pair.
Why this matters more than the existing reports
Most prior reports in this thread (and the closed duplicates #1709, #5761, #5934, #21095) describe accumulated history corruption — a Bash/Playwright/MCP tool returns bad Unicode and corrupts subsequent calls. Workarounds exist: start a new session,
sanitize console logs, etc.
This variant has no such workaround, because:
The entire desktop Code surface on macOS is non-functional. As a separate symptom, /agents returns a hardcoded "/agents isn't available in this environment" regardless — the surface clearly isn't a fully-fledged Claude Code instance.
Status of this bug class
For context for anyone arriving here: this bug class has been reported continuously since June 2025 (#1709), with multiple detailed reproductions, root-cause analysis (#16294 by @coygeek), and a one-line sanitization fix already proposed (#21095).
The pattern across reports is that the issue auto-closes for inactivity and gets refiled. Eight months in, on a paid product, with the proposed fix being roughly five lines of regex — this would benefit from someone on the team picking it up.
The desktop app variant pushes the impact wider: it's no longer a "long sessions occasionally break" bug, it's a "this entire product surface ships broken" bug.
I also have this issue and it keeps killing sessions of mine. Awful UX, please fix ASAP.
+1, hitting this on Windows 11 zh-CN (codepage 936 / GBK) with Claude Code 2.x, and I want to add a data point because every prior report in this thread is macOS/Linux or blames the tool's stdout for being invalid.
My case proves the corruption can originate inside Claude Code's own subprocess decoding layer, with the underlying tool emitting perfectly valid UTF-8.
## Origin mode: valid UTF-8 in → lone surrogate out, via cp936 +
surrogateescapeSource character:
—(U+2014, em dash). On the wire it's the standard 3-byte UTF-8 sequenceE2 80 94. Nothing wrong with the input.A Python child process spawned by the Bash tool reads/writes that text without explicit
encoding='utf-8'. On a Chinese-locale Windows host, CPython falls back tolocale.getpreferredencoding()= cp936, oftenpaired with
errors='surrogateescape'on stdio. The decode then splits as:| UTF-8 byte | cp936 decode result |
|---|---|
|
E2 80| valid GBK double-byte →鈥(U+9225) ||
94| no GBK continuation →surrogateescape→ U+DC94 (a lone low surrogate, PEP 383 slot) |That string then flows back into the conversation as a
role: usermessage, the JSONL is written withsurrogatepass, and every subsequent request to the API 400s exactly as described upstream.## Forensic evidence from a poisoned session
~/.claude/projects/<...>/<sessionId>.jsonl, line 641,role: user, position 692:``
``... # extract package 鈥\udc94 between 'anthropics_cowork_' ...
Raw bytes on disk:
``
``e9 88 a5 # 鈥 (U+9225, valid 3-byte UTF-8)
ed b2 94 # WTF-8 of lone low surrogate U+DC94
That
ed b2 94triplet is exactly whatstr.encode('utf-8', 'surrogatepass')emits for\udc94. So Claude Code is persisting the corruption to disk withsurrogatepass, then serializing it into the nextrequest body, where Anthropic's strict JSON parser correctly rejects it with
invalid high surrogate in string.## 5-second repro on any zh-CN / ja-JP / ko-KR Windows
``
python
``import subprocess
r = subprocess.run(['python', '-c', 'print("\u2014")'],
capture_output=True, text=True)
print(repr(r.stdout))
# zh-CN Windows: '鈥\udc94\n' ← lone surrogate, will poison any JSONL it lands in
# PYTHONUTF8=1 / Linux: '—\n'
Any code path that round-trips a child process's stdout back into the conversation (Bash tool, sub-agent transports, hook outputs, file-based agent comms) will then permanently brick the session.
## Why this expands the bug class
Prior reports in this thread (#16294, #21095, #45982, the macOS desktop Code-tab variant) blame the content of a tool's output:
My case is categorically different: the tool's stdout was 100% valid UTF-8. The lone surrogate was manufactured by Claude Code's own subprocess decoder on a non-en-US Windows. This means:
\udc94after the fact,but the conversation would still contain garbage like
鈥where the user/tool actually meant—. The fix has to happen further upstream.## Two-layer fix request
Layer 1 — prevent the corruption (Windows-specific, one-line):
For every subprocess spawned by the Bash tool, sub-agent transport, and hook runner, inject:
``
``env.PYTHONUTF8 = "1"
env.PYTHONIOENCODING = "utf-8"
and pass
encoding: 'utf-8'to allchild_process/subprocessinvocations. This deterministically eliminates the cp936-fallback origin mode without affecting Linux/macOS.Layer 2 — defense in depth (cross-platform, the regex everyone has been asking for since June 2025):
``
js
``const sanitizeSurrogates = (s) =>
typeof s === 'string'
? s.replace(
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g,
'\uFFFD',
)
: s;
Applied at the tool-result boundary, before the entry is appended to the session JSONL. Also: stop writing JSONL with
surrogatepass— refuse the write, or replace with U+FFFD, and surface a warning. Persisting lonesurrogates is what turns a one-shot tool failure into a permanently bricked session.
Layer 3 — recovery UX:
When the API returns
invalid_request_errorwith "invalid (high|low) surrogate in string", auto-locate the offending message in the current JSONL, scrub, retry. Today the only recovery is manual hex surgery, which isunreasonable on a paid product.
## User-side workaround for anyone bricked right now
``
bash
``python -c "
import re, shutil
p = r'<full-path-to-session>.jsonl'
shutil.copyfile(p, p + '.bak')
s = open(p, 'rb').read().decode('utf-8', 'surrogatepass')
s = re.sub(r'[\ud800-\udfff]', '', s)
open(p, 'wb').write(s.encode('utf-8'))
"
Then
claude --resume <sessionId>works again. (Or, if you don't care about history, just start a new session.)---
Verified against the latest docs; this still appears unresolved.
Hit this on v2.0.76 (Windows). Worth adding: for me it wasn't Bash output — the bad surrogate came from an AskUserQuestion
preview block. So sanitizing just the Bash tool result won't catch it; it needs to happen wherever the request body gets
serialized.
The bigger pain is that it doesn't just kill one turn. The bad line gets written into the session .jsonl, so every request
after that re-sends it and 400s. /compact, model switch, restart — all keep failing with the same error. The session is
basically dead.
If anyone's stuck with a bricked session: scan the transcript for the unpaired surrogate and fix it in place. Don't delete
the line — that breaks the parentUuid chain and resume loses all history. toWellFormed() handles the replacement:
const clean = s => s.toWellFormed(); // Node 20+, swaps unpaired surrogates for U+FFFDFixing the line in place instead of removing it let claude --resume reload the whole conversation.
I've been hitting this error a lot recently, made a hook to sanitize output: https://github.com/InfiniteLife/claude-code-surrogate-guard