cd /news/large-language-models/claude-structured-outputs-refusal-ha… · home topics large-language-models article
[ARTICLE · art-113715] src=dev.to ↗ pub= topic=large-language-models verified=true sentiment=· neutral

Claude Structured Outputs Refusal Handling: Stop Parsing HTTP 200 Refusals

A developer detailed a robust pattern for handling Claude structured outputs, emphasizing that an HTTP 200 response does not guarantee valid JSON and that applications must inspect the response envelope's stop_reason before deserialization. The approach classifies refusals and truncations as distinct states, preventing misleading parsing failures. The developer also noted the current API uses output_config.format for JSON Schema and recommended normalizing enum casing without weakening validation.

read5 min views1 publishedAug 28, 2026

Claude structured outputs refusal handling belongs before domain deserialization. A successful HTTP exchange only says the API accepted and processed the request; it does not guarantee that the text block contains the JSON object my application expects. Claude can return an HTTP 200 response with stop_reason: "refusal"

, and a response stopped by max_tokens

can contain incomplete JSON. If I unwrap content[0].text

and immediately call JsonSerializer.Deserialize

, I turn a documented response state into a misleading parsing failure.

The safer boundary is small: inspect the response envelope, classify the stop reason, and deserialize only a completed structured result.

For the current stable API, I put the JSON Schema under output_config.format

. This replaces the earlier beta output_format

request shape, and the beta header is no longer required. The official structured outputs guide documents the current request format and its exceptional cases.

The relevant part of a request looks like this:

{
  "output_config": {
    "format": {
      "type": "json_schema",
      "schema": {
        "type": "object",
        "properties": {
          "action": {
            "type": "string",
            "enum": ["approve", "escalate"]
          },
          "reason": { "type": "string" }
        },
        "required": ["action", "reason"],
        "additionalProperties": false
      }
    }
  }
}

Structured outputs normally give me schema-compliant JSON, but I still treat the envelope as authoritative. A refusal is a valid API response and may not follow my output schema. A max_tokens

stop can cut the generated document short. The stop-reason guidance explains why each reason needs deliberate handling instead of a blanket success path.

There is one more subtlety: enum and const

text can differ in letter casing. I do not weaken validation into “accept any string.” I normalize casing only while matching against the finite enum declared by my application.

I model decoding as a result, not an exception-driven happy path. The decoder first parses the outer message envelope, reads stop_reason

, and rejects refusal or truncation. Only then does it pass the text block to JsonSerializer.Deserialize

.

using System.Text.Json;
using System.Text.Json.Serialization;

public enum DecodeStatus { Success, Refusal, Truncated, InvalidPayload }
public enum ReviewAction { Approve, Escalate }

public sealed record ReviewWire(
    [property: JsonPropertyName("action")] string Action,
    [property: JsonPropertyName("reason")] string Reason);
public sealed record ReviewResult(ReviewAction Action, string Reason);
public sealed record DecodeResult(DecodeStatus Status, ReviewResult? Value);

public static DecodeResult Decode(string messageJson)
{
    using var message = JsonDocument.Parse(messageJson);
    var root = message.RootElement;
    var stopReason = root.GetProperty("stop_reason").GetString();

    // Gate on the response envelope before touching structured text.
    if (stopReason == "refusal")
        return new(DecodeStatus.Refusal, null);

    if (stopReason == "max_tokens")
        return new(DecodeStatus.Truncated, null);

    if (stopReason != "end_turn" || !TryGetTextBlock(root, out var text))
        return new(DecodeStatus.InvalidPayload, null);

    try
    {
        var wire = JsonSerializer.Deserialize<ReviewWire>(
            text,
            JsonSerializerOptions.Strict);

        if (wire is null || !TryMapAction(wire.Action, out var action))
            return new(DecodeStatus.InvalidPayload, null);

        return new(DecodeStatus.Success, new(action, wire.Reason));
    }
    catch (JsonException)
    {
        return new(DecodeStatus.InvalidPayload, null);
    }
}

static bool TryMapAction(string value, out ReviewAction action)
{
    if (value.Equals("approve", StringComparison.OrdinalIgnoreCase))
    {
        action = ReviewAction.Approve;
        return true;
    }

    if (value.Equals("escalate", StringComparison.OrdinalIgnoreCase))
    {
        action = ReviewAction.Escalate;
        return true;
    }

    action = default;
    return false;
}

This order is the key behavior. A refusal containing prose never reaches the inner deserializer. Truncated JSON is classified as truncation, not reported as a random syntax defect. An enum value such as APPROVE

maps to the declared Approve

member, while an undeclared value still fails.

In the complete sample, TryGetTextBlock

scans the content array instead of assuming the first block is text. The outer parser also turns a missing stop_reason

, missing text block, or malformed message body into an envelope failure. That separation keeps transport shape, generation outcome, and business data as three observable contracts rather than one catch-all JSON exception.

I keep API transport out of decoder tests. Small response fixtures make the contract deterministic and avoid paid model calls. At minimum, I test four messages: a valid end_turn

response, an HTTP 200 refusal with non-schema text, a max_tokens

response with truncated JSON, and a completed response whose enum casing differs.

Each test asserts the classification as well as the absence or presence of a domain value. That prevents a later refactor from moving deserialization above the stop-reason gate. It also avoids brittle assertions about generated wording.

I deliberately put invalid inner text in the refusal and truncation fixtures. If a future change parses either payload too early, the verifier fails for the wrong classification immediately. A separate end_turn

fixture contains malformed JSON and must produce InvalidPayload

; this proves the inner parser still reports a real contract defect when the envelope says generation completed.

The runnable sample includes those fixtures, source, and test commands. The associated pull request shows the complete change and validation record.

This decoder is intentionally strict: it accepts end_turn

for a request that expects one text result. If I intentionally use stop sequences, tool calls, or streaming, I need a state machine and an allowlist designed for those response paths. I would not silently treat every unfamiliar stop reason as success.

I also would not automatically retry a refusal. A refusal is not a transport outage, and retrying the same request can waste capacity without changing the outcome. For max_tokens

, a caller can choose to reduce the requested structure or adjust its token budget, but that policy belongs above the decoder.

Finally, case-insensitive enum matching is appropriate only when casing is not meaningful in the domain. The declared values remain the boundary; normalization should never turn arbitrary model text into an accepted business decision.

Where does your integration currently check stop_reason

: before deserialization, or only after parsing fails?

Happy coding!

── more in #large-language-models 4 stories · sorted by recency
── more on @claude 3 stories trending now
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-structured-ou…] indexed:0 read:5min 2026-08-28 ·