ehsan.blog
~/blog/how-to-get-structured-json-output-from-the-claude-api — zsh
cat how-to-get-structured-json-output-from-the-claude-api.md

How to get structured JSON output from the Claude API

·5 min read

I have a script that writes blog posts for me. It asks Claude for a title, a slug, some tags, and the body, then it drops a real .mdx file into this site’s content folder. For that to work automatically, I cannot get back a friendly paragraph of prose — I need a strict, predictable object my code can read fields off of. This is the difference between an LLM as a chat toy and an LLM as a piece of automation. This post zooms in on the schema-constrained call; for the full pipeline it sits inside — the content queue, the scheduler, and the commit — see auto-generating and publishing blog posts with a GitHub Action.

The trick is telling Claude to return structured JSON that matches a schema you define. Let me show you exactly how the real script does it.

Why structured output matters

If you just ask a model “give me a title and tags as JSON”, it usually cooperates — but “usually” is a landmine in automation. Sometimes it wraps the JSON in a code fence, sometimes it adds a chatty “Sure! Here you go:” preamble, sometimes it renames a field. Any of those breaks JSON.parse() and your script crashes at 3am when the scheduled job runs.

Schema-constrained output removes the guessing. You hand the model the exact shape you expect, and it returns data that conforms to it. Your parsing code stops being defensive and starts being simple.

Install the SDK and pick a model

First the dependency. This repo uses Anthropic’s official SDK:

bash
bun add @anthropic-ai/sdk

Then, at the top of the script, we import it and name the model as a constant:

mjs
// scripts/generate-post.mjs
import Anthropic from "@anthropic-ai/sdk"

const MODEL = "claude-opus-4-8"

Naming the model in one place matters — when a newer model ships, you change one line instead of hunting through the file. I use claude-opus-4-8 here because the writing quality justifies the cost for a once-a-day job; for high-volume work you would reach for a smaller, cheaper model.

The SDK reads your API key from the ANTHROPIC_API_KEY environment variable automatically, so creating the client needs no arguments:

mjs
// scripts/generate-post.mjs
const client = new Anthropic()

Never hardcode the key. Keep it in an env var (and out of git).

Define the schema

A JSON schema is just a description of the object you want: which fields exist, their types, and which are mandatory. Here is the real one from the generator:

mjs
// scripts/generate-post.mjs
const OUTPUT_SCHEMA = {
  type: "object",
  properties: {
    title: { type: "string", description: "Post title, no trailing period" },
    slug: {
      type: "string",
      description: "URL slug, lowercase kebab-case, ASCII only, 3–8 words",
    },
    description: {
      type: "string",
      description: "SEO meta description / excerpt, 1–2 sentences",
    },
    tags: {
      type: "array",
      items: { type: "string" },
      description: "3–6 lowercase kebab-case topic tags",
    },
    body_markdown: {
      type: "string",
      description: "The full post body in Markdown, per the rules",
    },
  },
  required: ["title", "slug", "description", "tags", "body_markdown"],
  additionalProperties: false,
}

A few things I want beginners to notice, because they carry real weight:

  • The description on each field is not a comment for you — the model reads it. Treat these as instructions. “no trailing period”, “lowercase kebab-case” — the model honors them.
  • required lists the fields that must be present. If it is missing, that is a bug in your prompt, not something you handle at parse time.
  • additionalProperties: false says “do not invent extra fields.” This keeps the output tight and predictable.

Make the call with format: { type: "json_schema", schema }

Now the actual request. This is the modern Claude API shape — the schema is passed inside output_config.format:

mjs
// scripts/generate-post.mjs
const message = await client.messages.create({
  model: MODEL,
  max_tokens: 16000,
  thinking: { type: "adaptive" },
  output_config: {
    effort: "high",
    format: { type: "json_schema", schema: OUTPUT_SCHEMA },
  },
  system: PERSONA,
  messages: [
    {
      role: "user",
      content: `${brief}\n\n${RULES}${suggestedTags}${avoid}`,
    },
  ],
})

Walking through the important parts:

  • format: { type: "json_schema", schema: OUTPUT_SCHEMA } is the whole point. It tells the model to emit JSON conforming to your schema rather than free text.
  • system is the persona/instructions that stay constant; messages carries the actual task. Splitting “who you are” from “what to do” keeps prompts clean.
  • max_tokens caps the response length — set it high enough for your biggest expected output (a full post here).
  • thinking and output_config.effort let this model reason before answering, which improves quality on a substantial writing task.

Read the result safely

Even with schema output, you still pull the text out of the response and parse it. The response content is an array of blocks; grab the text block and JSON.parse it:

mjs
// scripts/generate-post.mjs
function extractJson(message) {
  const block = message.content.find((b) => b.type === "text")
  if (!block) throw new Error("No text block in model response")
  return JSON.parse(block.text)
}

One more guard the real script keeps: a model can decline a request. Check stop_reason before trusting the output:

mjs
// scripts/generate-post.mjs
if (message.stop_reason === "refusal") {
  console.error("Model refused to generate this topic. Skipping.")
  process.exit(1)
}

const post = extractJson(message)

After that, post.title, post.slug, post.tags, and post.body_markdown are ordinary JavaScript values. The script builds frontmatter and writes the file — no regex scraping, no “did it wrap this in backticks again” hacks.

Here is the end-to-end flow:

flowchart LR
  A[Define OUTPUT_SCHEMA] --> B[messages.create with format json_schema]
  B --> C{stop_reason refusal?}
  C -- yes --> D[Skip / exit]
  C -- no --> E[extractJson -> JSON.parse]
  E --> F[Use post.title, post.slug, ...]

What to remember

  • Structured output is what turns an LLM from a chatbot into a reliable step in a pipeline.
  • Define a JSON schema with type, properties, required, and additionalProperties: false — and use each field’s description as an instruction to the model.
  • Pass it with output_config.format: { type: "json_schema", schema } on the modern messages.create call.
  • Still parse defensively: find the text block, JSON.parse it, and check stop_reason === "refusal" before trusting the data.
  • Keep the model id in one constant and the API key in an env var, never in code.

Related reading: the generator can also fire a “published” notification email — see sending email from a serverless function with Resend.

ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.