DEV Community

Multigrid
Multigrid

Posted on • Originally published at multigrid.ai

Forcing JSON Output From Claude Without a Dedicated JSON Mode

There is no response_format on the Claude Messages API and no JSON mode flag. That is not an omission; the tool-use machinery already takes a JSON schema and returns a validated object, so Anthropic documents that as the structured-output path.

The parameter that is not there

If you are porting from an API that has one, this is the first wall you hit. Sending response_format to /v1/messages is not silently ignored — unknown fields are rejected — so the migration announces itself immediately, which is preferable to the alternative.

The instinct is to solve it in the prompt: “respond with JSON only, no markdown, no preamble”. That works most of the time and fails in the ways that are worst to handle — a code fence around the object, a sentence of explanation before it, a trailing comma, an apology when the model is uncertain. Each of those is a parse error in production at some low rate, and low-rate parse errors are expensive because they are hard to reproduce.

The usual second move is a repair layer: strip anything before the first brace, strip code fences, try to parse, retry the whole request if it fails. That works, and it is worth understanding what it costs. Every repair heuristic is a guess about a failure you have already seen, so the layer grows one clause at a time and never covers the next shape. The retry doubles latency and cost on exactly the requests that were already slow. And none of it validates the field names or the enum values — a syntactically perfect object with "priority" where your code expects "severity" passes the parser and fails downstream.

A schema is a tool

The mechanism is a reframing rather than a workaround. A tool definition is a name, a description and an input_schema in JSON Schema. When the model decides to call a tool, it emits a tool_use content block whose input is an object shaped by that schema. That is structured output already — the only thing standing between you and it is the word “decides”.

tool_choice removes the decision. It takes four documented forms:

  • {"type": "auto"} — the model chooses whether to use a tool. The default when tools are supplied.
  • {"type": "any"} — the model must use one of the tools, but picks which.
  • {"type": "tool", "name": "..."} — the model must use this tool. This is the one that produces structured output.
  • {"type": "none"} — no tools may be used.

So: define one tool whose input_schema is your desired output shape, force it by name, and the response is your object. The tool does not have to exist as a function anywhere. It is never executed and nothing is sent back to it. It is a schema wearing a costume.

Understanding it that way rather than as a trick matters for how you write the definition. Everything the model is given about the tool — the name, the description, every property description — is text in the prompt that shapes the output. A tool named f with an empty description and properties called a and b is a valid schema and a poor instruction. Name the tool for the act of recording the result, describe it in a sentence, and treat the property descriptions as the place where the constraints JSON Schema cannot express are stated.

Tool use, tool_choice values and the tool_use block shape are documented in Anthropic’s tool use documentation. One honest caveat: the schema is enforced far more strongly than a prompt instruction, but Anthropic does not describe it as a constrained-decoding grammar guarantee in the way some providers describe a strict structured-output mode. Validate the parsed object against your schema on receipt anyway — enums and numeric ranges are the fields worth checking.

The worked example

Extracting structured fields from an unstructured support message. The tool is named for what it records, described so the model knows when it applies, and its schema is the contract:

curl https://api.anthropic.com/v1/messages \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  -d '{
    "model": "claude-sonnet-4-5-20250929",
    "max_tokens": 1024,
    "temperature": 0,
    "tools": [
      {
        "name": "record_ticket",
        "description": "Record the structured fields extracted from a support ticket.",
        "input_schema": {
          "type": "object",
          "properties": {
            "severity":   {"type": "string", "enum": ["low", "medium", "high", "critical"]},
            "component":  {"type": "string", "description": "The affected subsystem."},
            "summary":    {"type": "string", "description": "One sentence, under 20 words."},
            "regions":    {"type": "array", "items": {"type": "string"}},
            "started_at": {"type": ["string", "null"], "description": "ISO 8601, or null if not stated."}
          },
          "required": ["severity", "component", "summary", "regions", "started_at"]
        }
      }
    ],
    "tool_choice": {"type": "tool", "name": "record_ticket"},
    "messages": [
      {"role": "user", "content": "Checkout has been returning 500s for EU cards since about 09:14 UTC. UK and Germany confirmed. Card payments only, PayPal is fine."}
    ]
  }'
Enter fullscreen mode Exit fullscreen mode

Three details in that body are doing real work.

  • Every field is in required. An optional field is one the model may omit, and a missing key is harder to handle than an explicit null. Make everything required and allow null in the type where the value may genuinely be absent, as started_at does here.
  • The descriptions are prompts. The description on each property is read by the model and is the right place for constraints that JSON Schema cannot express — “one sentence, under 20 words”.
  • temperature is 0. Extraction is the canonical case for it.

Reading the response

The response contains a tool_use block, and stop_reason is tool_use rather than end_turn:

{
  "id": "msg_01Aq9w8dMvQ...",
  "model": "claude-sonnet-4-5-20250929",
  "role": "assistant",
  "stop_reason": "tool_use",
  "content": [
    {
      "type": "tool_use",
      "id": "toolu_01A09q90qw90lq917835lq9",
      "name": "record_ticket",
      "input": {
        "severity": "high",
        "component": "checkout",
        "summary": "EU card payments returning 500 errors since 09:14 UTC.",
        "regions": ["UK", "Germany"],
        "started_at": "2026-08-11T09:14:00Z"
      }
    }
  ],
  "usage": {"input_tokens": 512, "output_tokens": 96}
}
Enter fullscreen mode Exit fullscreen mode

input arrives as a parsed object. There is no JSON string to decode, no fence to strip, and no preamble to discard — which is the concrete advantage over prompting for JSON, quite apart from the reliability:

block = next(b for b in response.content if b.type == "tool_use")
ticket = block.input                    # already a dict
assert ticket["severity"] in {"low", "medium", "high", "critical"}
Enter fullscreen mode Exit fullscreen mode

Two behaviours to handle. Because the tool is forced, the model cannot answer in prose, so there may be no text block at all — code that reads content[0].text will fail. Iterate and match on type. And with several tools available the model can emit multiple tool_use blocks in one response; forcing a single named tool is the simplest way to avoid that, and disabling parallel tool use is the explicit control where you need more than one tool defined.

When streaming, the tool input arrives as input_json_delta events carrying partial JSON strings that you accumulate and parse when the block closes. Do not attempt to parse the fragments as they arrive; they are not valid JSON individually.

One cost to weigh against the reliability. Forcing a tool means the model has no way to tell you that the input did not contain what you asked for. Given a support message with no severity information, it must still emit a severity, and the value it picks is a guess presented in the same shape as a fact. The fix is in the schema rather than in the prompt: allow the honest answer to be representable — a nullable field, an unknown enum member, or a separate boolean for whether the extraction was possible. A schema with no way to say “not stated” guarantees fabrication at some rate.

The cheaper alternative, and its cost

There is a second documented technique: put words in Claude’s mouth by ending the messages array with an assistant turn. The model continues from where you left it.

"messages": [
  {"role": "user", "content": "Extract severity and component as JSON."},
  {"role": "assistant", "content": "{"}
]
Enter fullscreen mode Exit fullscreen mode

The response now begins mid-object, so there is no preamble and no code fence — you re-attach the opening brace and parse. It costs nothing extra, whereas the tool definition adds its schema to every request as input tokens.

What it gives up is the schema. Nothing constrains the field names, the types or the enum values; you are back to hoping, with one class of failure removed. It is a good fit for a small, obvious shape and a poor one for anything with an enum you intend to switch on. It is also incompatible with extended thinking, which does not permit a prefilled assistant turn. The full technique is on the assistant prefill page.

The awkward part of this design is portability. Structured output on Claude is a forced tool call; on other APIs it is a response_format field, and on others again a response schema object. The same extraction therefore needs three request shapes and three response-parsing paths, and that is the code that rots when you add a fourth model. A gateway that normalises structured output to one request shape moves that translation out of your application — which is worth knowing about whether or not you use one, because it is the part of a multi-provider integration that is most often underestimated.

Related

Top comments (0)