{"slug": "claude-returned-json-blocks-14-of-the-time-here-is-the-rust-crate-i-wish-i-had", "title": "Claude returned ```json blocks 14% of the time. Here is the Rust crate I wish I had earlier.", "summary": "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.", "body_md": "I had a system prompt that ended with: Reply with only a JSON object. Do not include code fences. Do not include explanation.\nThat should be enough. It is not.\nOver 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\nattempt. Result: 86.0%. The other 14% had at least one of these problems:\njson ...\nwrappers (most common, about 9.3% of total)So I wrote llm-json-repair\n. 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.\nuse llm_json_repair::repair;\nlet raw = r#\"```\njson\n{\n\"intent\": \"book_flight\",\n\"slots\": {\n\"origin\": \"DAL\",\n\"destination\": \"JFK\",\n},\n}\n```\"#;\nlet cleaned = repair(raw)?;\nlet parsed: serde_json::Value = serde_json::from_str(&cleaned)?;\nWhat repair\nactually does, in order:\nLook for the first ` and the last `\n. If both exist, take what is between them. The optional\njson\nlanguage tag gets dropped along with the opening fence. If there is text after the closing fence, it is discarded.\nThis pass is a one-liner conceptually, but the edge case that bit me was a JSON string value that contained the literal substring `\n(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.\nWalk character by character. Find the first {\nor [\n. Count nesting. Stop when nesting hits zero. Return that substring.\nThis 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.\n`rust\nlet raw = \"Sure thing! Here is the JSON:\\n{\\\"intent\\\": \\\"refund\\\", \\\"reason\\\": \\\"late\\\"}\\nLet me know if you have questions.\";\nlet cleaned = repair(raw)?;\nassert_eq!(cleaned, r#\"{\"intent\": \"refund\", \"reason\": \"late\"}\"#);\n`\nThe cost of this pass is O(n) over the response. For a 4 KB response it runs in about 30 microseconds on my laptop.\nWalk the candidate JSON. For each ,\nfollowed only by whitespace and then }\nor ]\n, delete the comma. Skip when inside a string literal (track quote state with escape handling).\nThis 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\nrejects it. Fixing it locally saves you a round trip.\nRaw response:\n`json\njson\n`\n{\n\"summary\": \"User wants to cancel order #4419\",\n\"actions\": [\n{\"name\": \"cancel_order\", \"args\": {\"id\": 4419}},\n{\"name\": \"notify_user\", \"args\": {\"channel\": \"email\",}},\n],\n}\n`\njson\n`\nThree problems: fences, two trailing commas, valid JSON otherwise. After repair\n:\n`json\n{\n\"summary\": \"User wants to cancel order #4419\",\n\"actions\": [\n{\"name\": \"cancel_order\", \"args\": {\"id\": 4419}},\n{\"name\": \"notify_user\", \"args\": {\"channel\": \"email\"}}\n]\n}\n`\nParses cleanly. No second LLM call.\nIf all three passes run and the result still does not parse, repair\nreturns an error. You should not silently swallow that. The crate exposes try_repair\nwhich returns Result<String, RepairError>\nand gives you the failing pass in the error variant.\n`rust\nmatch try_repair(raw) {\nOk(s) => use_json(s),\nErr(RepairError::Pass3(_)) => {\n// bracket structure is fine but content is wrong\n// worth one model-side retry with the error attached\n}\nErr(e) => log::warn!(\"could not repair: {e}\"),\n}\n`\nA few honest limits.\nagentcast-rs\nif you need a validate-and-retry loop.serde\nderives or jsonschema\non top.The whole crate is about 350 lines of safe Rust with no async. It works as a sync function or inside any runtime.\nRepo: https://github.com/MukundaKatta/llm-json-repair\ncrates.io: llm-json-repair = \"0.1\"\nPart of a small set of Rust crates I publish for the unglamorous LLM plumbing: parsing, cost, retry, budget, repair. Sibling crates: claude-cost\n, llm-retry\n, agentcast-rs\n.", "url": "https://wpnews.pro/news/claude-returned-json-blocks-14-of-the-time-here-is-the-rust-crate-i-wish-i-had", "canonical_source": "https://dev.to/mukundakatta/claude-returned-json-blocks-14-of-the-time-here-is-the-rust-crate-i-wish-i-had-earlier-4dp6", "published_at": "2026-05-21 01:52:16+00:00", "updated_at": "2026-05-21 02:02:38.229318+00:00", "lang": "en", "topics": ["large-language-models", "developer-tools", "open-source"], "entities": ["Claude", "Sonnet", "llm-json-repair", "serde_json", "Rust"], "alternates": {"html": "https://wpnews.pro/news/claude-returned-json-blocks-14-of-the-time-here-is-the-rust-crate-i-wish-i-had", "markdown": "https://wpnews.pro/news/claude-returned-json-blocks-14-of-the-time-here-is-the-rust-crate-i-wish-i-had.md", "text": "https://wpnews.pro/news/claude-returned-json-blocks-14-of-the-time-here-is-the-rust-crate-i-wish-i-had.txt", "jsonld": "https://wpnews.pro/news/claude-returned-json-blocks-14-of-the-time-here-is-the-rust-crate-i-wish-i-had.jsonld"}}