npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

llm-json-extract

v2.0.0

Published

Extract & validate JSON from messy LLM output. Designed for Claude / Codex CLI workflows where tool-use is unavailable. XML-tag aware, with multi-stage fallbacks and built-in repair.

Readme

  _ _                 _                                 _                  _
 | | |_ __ ___       (_)___  ___  _ __         _____  _| |_ _ __ __ _  ___| |_
 | | | '_ ` _ \ _____| / __|/ _ \| '_ \ _____ / _ \ \/ / __| '__/ _` |/ __| __|
 | | | | | | | |_____| \__ \ (_) | | | |_____|  __/>  <| |_| | | (_| | (__| |_
 |_|_|_| |_| |_|    _/ |___/\___/|_| |_|      \___/_/\_\\__|_|  \__,_|\___|\__|
                   |__/
       reasoning · prose · almost-JSON  ──▶  { clean, typed JSON }  ✓

Get clean, validated JSON out of any LLM response — even when the model rambles.

npm version CI npm downloads bundle size types license

You ask a model for JSON. It gives you this:

Sure! Let me think about this.

<thinking>The user wants three fruits with prices...</thinking>

Here's the data you asked for:

<result>
{
  "items": ['apple', 'banana', 'cherry'],  // single quotes + a comment
  "count": 3,                              // ...and a trailing comma
}
</result>

Hope that helps! Let me know if you need anything else.

One function call later, you have what you actually wanted:

import { extractJson } from "llm-json-extract";

const data = extractJson(llmOutput);
// { items: ["apple", "banana", "cherry"], count: 3 }

Add a schema and it's fully typed and validated too:

import { extractJsonWith } from "llm-json-extract";
import { z } from "zod";

const Schema = z.object({ items: z.array(z.string()), count: z.number() });

const data = extractJsonWith(llmOutput, Schema);
//    ^? { items: string[]; count: number }

Or from the shell, with no glue code at all:

claude -p '...' | npx llm-json-extract

No prompt gymnastics. No "Respond with ONLY JSON, nothing else!!" begging. Let the model think out loud — this library finds the answer.


Why does this exist?

The Anthropic API has tool use. OpenAI has Structured Outputs. But a huge amount of real-world LLM plumbing never touches those APIs:

  • Shelling out to claude -p or codex exec from a script — you get free-form text back, period.
  • Local models via Ollama / llama.cpp, where JSON mode is unreliable or unavailable.
  • Agent pipelines where the model's response mixes reasoning, tool chatter, and the answer.
  • Any place you ask for JSON via prompt instead of via API parameter.

And there's a quality angle: forcing a model into JSON-only output often hurts accuracy on reasoning-heavy tasks. The pattern that works better — and the one Anthropic recommends — is to let the model reason freely and wrap its final answer in an XML tag like <result>…</result>, then extract it.

llm-json-extract is that pattern, productionized: tag extraction with layered fallbacks for every way models don't quite follow instructions, plus built-in repair for the almost-JSON they produce, plus schema validation that automatically skips wrong candidates.

Highlights

  • 🧠 Prose-tolerant — reasoning, apologies, and closing pleasantries are all ignored; only the answer comes out
  • 🏷️ XML-tag aware<result>, <json>, <output> by default; fully configurable
  • 🪜 Layered fallbacks — tag → ```json fence → bare fence → balanced {…} / […] in raw text
  • 🔁 Parse-aware fallthrough — if the best candidate fails to parse (or fails your schema), the next one is tried automatically
  • 🎯 Example-echo safepickLast grabs the final <result> block, not the example the model copied from your prompt
  • 🩹 Repairs almost-JSON — trailing commas, single quotes, comments, unquoted keys, and truncated containers via built-in repair
  • Bring your own validator — pass a zod schema directly, or any (unknown) => T function (valibot, arktype, hand-rolled)
  • 🖥️ npx llm-json-extract CLI included — pipe claude -p / codex exec output straight through, zero glue code
  • 🪶 Tiny & dependable — zero runtime dependencies, ESM + CJS, full TypeScript types, tree-shakeable
  • 🧯 Typed errorsLlmJsonExtractError tells you which stage failed (extract / parse / validate) and hands you the raw text for debugging

Install

npm install llm-json-extract
# pnpm add llm-json-extract
# yarn add llm-json-extract

Requires Node 18+ (tested on 20 / 22 / 24 / 26; Node 18 is EOL — covered by a smoke test only). Works in ESM and CommonJS.

60-second tutorial

1. Prompt the model — ask it to wrap the answer, and explicitly allow prose:

Think through the problem step by step, then put your final JSON answer
in <result>...</result>. Anything outside the tags is ignored.

2. Extract:

import { extractJson } from "llm-json-extract";

const data = extractJson(llmOutput); // unknown — parsed JSON

3. Or extract + validate in one step (recommended — wrong candidates that happen to parse are skipped until one satisfies your schema):

import { extractJsonWith } from "llm-json-extract";
import { z } from "zod";

const User = z.object({
  name: z.string(),
  age: z.number(),
  hobbies: z.array(z.string()),
});

const user = extractJsonWith(llmOutput, User);
//    ^? z.infer<typeof User>

That's the whole API for most users. Two more functions exist for advanced cases — see API.

What it survives

Real model output is messy in predictable ways. All of these extract cleanly with the defaults:

| The model did this | What happens | | --- | --- | | Wrapped the answer in <result>…</result> with prose around it | Tag body is extracted — the happy path | | Used <json> or <output> instead | Also matched by default; tag list is configurable | | Echoed your prompt's <result> example and gave a real answer | pickLast picks the final block; the echo is still tried as a fallback | | Ignored the tags and used a ```json code fence | Fence fallback catches it | | Ignored the fence too and dumped bare JSON mid-paragraph | Balanced {…} / […] scanner catches it | | Emitted trailing commas, comments, single quotes, unquoted keys | Built-in repair fixes it before JSON.parse | | Put triple backticks inside a JSON string value | Fence parsing is CommonMark-aware; the fence doesn't end early | | Produced a first candidate that parses but fails your schema | extractJsonWith moves on to the next candidate | | Returned nothing JSON-shaped at all | Throws LlmJsonExtractError with stage and the raw text |

API

| Function | Returns | Use when | | --- | --- | --- | | extractJsonWith(text, schema, opts?) | T (validated) | Default choice. You know the shape you expect | | extractJson(text, opts?) | unknown (parsed) | You'll validate or inspect it yourself | | extractJsonString(text, opts?) | string \| null | You want the raw JSON substring (piping, logging) | | extractJsonCandidates(text, opts?) | string[] | You want every candidate to score/pick yourself |

extractJsonWith — extract, parse, validate

Accepts anything with a .parse(unknown) => T method (zod schemas work as-is) or any plain function (unknown) => T that throws on bad input:

// zod — pass the schema directly
const user = extractJsonWith(text, UserSchema);

// valibot
import * as v from "valibot";
const user = extractJsonWith(text, (x) => v.parse(UserSchema, x));

// arktype
import { type } from "arktype";
const User = type({ name: "string", age: "number" });
const user = extractJsonWith(text, (x) => User.assert(x));

// no library at all
const user = extractJsonWith(text, (x) => {
  if (typeof x !== "object" || x === null || !("name" in x)) throw new Error("nope");
  return x as { name: string };
});

Candidates that parse but fail validation are skipped in favor of the next one — this is what makes stray object literals in prose harmless.

extractJson — extract and parse

const data = extractJson(llmOutput); // unknown

Tries each candidate in order and returns the first that parses. Structured results (objects/arrays) are preferred over primitives, so repair turning a stray prose word into a JSON string can't mask the real answer.

extractJsonString / extractJsonCandidates

const jsonStr = extractJsonString(llmOutput);      // best candidate, unparsed
const all = extractJsonCandidates(llmOutput);      // every candidate, ordered by preference

CLI

The package ships a command-line tool with the same extraction pipeline, so shell scripts get the same robustness as code — no glue required:

# pipe from stdin
claude -p 'List 3 fruits. Reply as <result>{"items":[...]}</result>.' | llm-json-extract

# or read a file, pretty-printed
llm-json-extract --pretty response.txt

(Use npx llm-json-extract if it isn't installed globally or in the current project.)

| Flag | Effect | | --- | --- | | -t, --tag <name> | Tag to scan for (repeatable; replaces the defaults result, json, output) | | --first | Prefer the first tag match instead of the last | | --no-fence / --no-bare / --no-repair | Disable individual fallback/repair stages | | -r, --raw | Print the extracted candidate as-is, without parsing or repairing | | -p, --pretty | Pretty-print the parsed JSON (2-space indent) | | -h, --help / -V, --version | Help / version |

Exit codes: 0 success, 1 nothing extracted or parsed, 2 usage error — so || fallbacks and retry loops in shell scripts just work.

Recipes

Wrap codex exec (or any CLI) in a typed function

import { execSync } from "node:child_process";
import { extractJsonWith } from "llm-json-extract";
import { z } from "zod";

const Fruits = z.object({ items: z.array(z.string()) });

function askForFruits(): z.infer<typeof Fruits> {
  const out = execSync(`codex exec "List 3 fruits as <result>{...}</result>"`, {
    encoding: "utf8",
  });
  return extractJsonWith(out, Fruits);
}

Retry loop that feeds the failure back to the model

LlmJsonExtractError carries everything you need to tell the model what went wrong:

import { extractJsonWith, LlmJsonExtractError } from "llm-json-extract";

async function askWithRetry<T>(prompt: string, schema: { parse: (x: unknown) => T }) {
  let lastHint = "";
  for (let attempt = 0; attempt < 3; attempt++) {
    const output = await callModel(prompt + lastHint);
    try {
      return extractJsonWith(output, schema);
    } catch (e) {
      if (!(e instanceof LlmJsonExtractError)) throw e;
      lastHint = `\n\nYour previous reply failed at the "${e.stage}" stage` +
        `${e.extracted ? ` on: ${e.extracted}` : ""}. ` +
        `Reply again with valid JSON inside <result>...</result>.`;
    }
  }
  throw new Error("Model never produced valid JSON");
}

Prompting tips

The library's superpower is that you don't have to suppress the model's reasoning — so don't. Prompts like this get better answers and parse reliably:

Think step by step. When you're done, wrap your final JSON answer in
<result>...</result>. Prose outside the tags is fine and will be ignored.

If your prompt contains a formatting example, use a different tag for it so it can never be confused with the answer:

Example format (do not copy these values):
<example>{"score": 0}</example>

Your real answer goes in <result>...</result>.

(Even if the model echoes a <result> example, pickLast usually saves you — the real answer comes last. The distinct tag just makes it bulletproof.)

Options

Every function takes the same options object. These are the defaults:

extractJson(llmOutput, {
  tags: ["result", "json", "output"], // tag names to scan for (case-insensitive, attributes OK)
  pickLast: true,     // prefer the tag match closest to the end of the text
  tryCodeFence: true, // fall back to ```json / ``` fenced blocks
  tryBareJson: true,  // fall back to balanced {...} / [...] runs in raw text
  repair: true,       // repair common LLM JSON mistakes before JSON.parse
});

Note: tag priority is decided by document position (per pickLast), not by order in the tags array.

Error handling

All failures throw a single, inspectable error type:

import { extractJson, LlmJsonExtractError } from "llm-json-extract";

try {
  const data = extractJson(llmOutput);
} catch (e) {
  if (e instanceof LlmJsonExtractError) {
    e.stage;     // "extract" — nothing JSON-shaped found
                 // "parse"   — candidates found, none parsed
                 // "validate"— parsed, but your schema rejected everything
    e.raw;       // the full original input
    e.extracted; // the substring that was attempted (or null)
    e.cause;     // the underlying JSON.parse / validator error
  }
}

How it works

input text
   │
   ├─ 1. tag matches        <result>…</result>, <json>…</json>, <output>…</output>
   │       preferred match first (pickLast), then the rest in document order
   ├─ 2. code fences        ```json blocks first, then bare ``` blocks
   └─ 3. bare JSON          balanced {…} / […] runs, string- and escape-aware
   │
   ▼
candidate list ──► for each: built-in repair → JSON.parse → (your validator)
                             first success wins; objects/arrays beat primitives

Details worth knowing:

  • Tag matching is case-insensitive and tolerates attributes (<result lang="json">).
  • The bare-JSON scanner respects JSON strings, escapes, and // / /* */ comments, so braces inside string values never confuse it.
  • Fence parsing follows CommonMark closing rules — a ``` inside a JSON string won't terminate the block.
  • Everything is a single linear scan per strategy; the fence regexes were specifically hardened against ReDoS.
  • The independent built-in repairer covers the documented regular-parser categories from jsonrepair 3.15, including HTML-encoded quotes. Severely ambiguous malformed inputs can still be interpreted differently.
  • There is no streaming repair API: this project extracts candidates from complete text, so streamed input must be buffered first.

When not to use this

If you're calling the Anthropic or OpenAI API directly, use tool use (Claude) or Structured Outputs (OpenAI). They enforce the schema at the decoding step — a stronger guarantee than any post-hoc parser can give. This library is for all the places where that option doesn't exist.

| | Provider structured output | JSON.parse | jsonrepair alone | llm-json-extract | | --- | :-: | :-: | :-: | :-: | | Works with CLI / local / prompt-only output | ❌ | ✅ | ✅ | ✅ | | Finds JSON buried in prose & reasoning | — | ❌ | ❌ | ✅ | | Repairs trailing commas, comments, quotes | — | ❌ | ✅ | ✅ | | Multiple candidates with fallback | — | ❌ | ❌ | ✅ | | Schema validation with candidate retry | ✅ (enforced) | ❌ | ❌ | ✅ |

FAQ

Does it need zod? No. There are zero runtime dependencies. Validation is opt-in and works with zod, valibot, arktype, or any function that throws on bad input.

What if the model outputs only JSON, no tags or prose? Works fine — the bare-JSON fallback picks it up. Tags just make extraction unambiguous.

Can I use my own tag names? Yes: extractJson(text, { tags: ["answer"] }).

Does it handle streaming? Buffer the stream first — extraction operates on complete text. (The CLI already does this when you pipe into it.)

How big is it? A few kilobytes with no runtime dependencies. Check the bundle badge at the top for the current min+gzip number.

Contributing

Bug reports and PRs are welcome — see CONTRIBUTING.md. The test suite (npm test) covers every fallback path, and CI runs on Node 20–26.

License

MIT © Keisuke Karijuku