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.
The schema is only one part of the contract
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.
Make Claude structured outputs refusal handling a decoder boundary
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.
Prove the edge cases offline
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.
Limitations and when not to use this pattern
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!
Top comments (0)