cd /news/large-language-models/claude-returned-json-blocks-14-of-th… · home topics large-language-models article
[ARTICLE · art-4273] src=dev.to pub= topic=large-language-models verified=true sentiment=· neutral

Claude returned ```json blocks 14% of the time. Here is the Rust crate I wish I had earlier.

A Rust crate called `llm-json-repair` created to fix common formatting issues in JSON responses from Claude AI models. The author found that 14% of 12,400 structured-output API calls to Claude Sonnet 4.5 and 4.7 produced unparseable JSON, with the most common problems being code fences (9.3% of total) and trailing commas. The crate applies three sequential passes—stripping markdown code fences, extracting the first complete JSON object, and removing trailing commas—to repair responses locally without requiring additional LLM API calls.

read3 min views8 publishedMay 21, 2026

I had a system prompt that ended with: Reply with only a JSON object. Do not include code fences. Do not include explanation. That should be enough. It is not. Over a week of structured-output calls (12,400 of them, mostly Claude Sonnet 4.5 and 4.7), I logged how often the response was actually parseable as JSON on the first serde_json::from_str attempt. Result: 86.0%. The other 14% had at least one of these problems: json ... wrappers (most common, about 9.3% of total)So I wrote llm-json-repair . Three passes, applied in order, each one cheap, none of them re-invoking the LLM. If a downstream model fix is needed, that is your problem to glue on. This crate is the local cleanup before you spend another API call. use llm_json_repair::repair; let raw = r#"``` json { "intent": "book_flight", "slots": { "origin": "DAL", "destination": "JFK", }, }

let cleaned = repair(raw)?;
let parsed: serde_json::Value = serde_json::from_str(&cleaned)?;
What repair
actually does, in order:
Look for the first ` and the last `
. If both exist, take what is between them. The optional
json
language tag gets dropped along with the opening fence. If there is text after the closing fence, it is discarded.
This pass is a one-liner conceptually, but the edge case that bit me was a JSON string value that contained the literal substring `
(yes, a customer-support transcript got fed in once). So the matcher only strips fences at the outermost level and only if they are on their own line or at the start.
Walk character by character. Find the first {
or [
. Count nesting. Stop when nesting hits zero. Return that substring.
This drops leading prose ("Here is the JSON:") and trailing prose ("Let me know if you need anything else.") without needing a model. It also handles the case where the model emitted two JSON objects in a row (rare but real); you get the first complete one.
`rust
let raw = "Sure thing! Here is the JSON:\n{\"intent\": \"refund\", \"reason\": \"late\"}\nLet me know if you have questions.";
let cleaned = repair(raw)?;
assert_eq!(cleaned, r#"{"intent": "refund", "reason": "late"}"#);
`
The cost of this pass is O(n) over the response. For a 4 KB response it runs in about 30 microseconds on my laptop.
Walk the candidate JSON. For each ,
followed only by whitespace and then }
or ]
, delete the comma. Skip when inside a string literal (track quote state with escape handling).
This is the single most common syntactic failure I see from Claude when it is generating a long object: it puts a trailing comma after the last field. It is a small thing but serde_json
rejects it. Fixing it locally saves you a round trip.
Raw response:
`json
json
`
{
"summary": "User wants to cancel order #4419",
"actions": [
{"name": "cancel_order", "args": {"id": 4419}},
{"name": "notify_user", "args": {"channel": "email",}},
],
}
`
json
`
Three problems: fences, two trailing commas, valid JSON otherwise. After repair
:
`json
{
"summary": "User wants to cancel order #4419",
"actions": [
{"name": "cancel_order", "args": {"id": 4419}},
{"name": "notify_user", "args": {"channel": "email"}}
]
}
`
Parses cleanly. No second LLM call.
If all three passes run and the result still does not parse, repair
returns an error. You should not silently swallow that. The crate exposes try_repair
which returns Result<String, RepairError>
and gives you the failing pass in the error variant.
`rust
match try_repair(raw) {
Ok(s) => use_json(s),
Err(RepairError::Pass3(_)) => {
// bracket structure is fine but content is wrong
// worth one model-side retry with the error attached
}
Err(e) => log::warn!("could not repair: {e}"),
}
`
A few honest limits.
agentcast-rs
if you need a validate-and-retry loop.serde
derives or jsonschema
on top.The whole crate is about 350 lines of safe Rust with no async. It works as a sync function or inside any runtime.
Repo: https://github.com/MukundaKatta/llm-json-repair
crates.io: llm-json-repair = "0.1"
Part of a small set of Rust crates I publish for the unglamorous LLM plumbing: parsing, cost, retry, budget, repair. Sibling crates: claude-cost
, llm-retry
, agentcast-rs
.
── more in #large-language-models 4 stories · sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/claude-returned-json…] indexed:0 read:3min 2026-05-21 ·