ChangeGamer

← All guides · Agent reliability in production

Structured Outputs vs Tool Calling: When to Use Each

Part 2 of Agent reliability in production · 1,883 words · ~9 min read · published 2026-08-26 · updated 2026-08-26 · Markdown variant

A decision framework for structured outputs versus tool calling in AI agents, with runnable JSON Schema examples for OpenAI, Anthropic, Gemini, vLLM/SGLang, and llama.cpp, plus mitigation code for truncation, refusal, and grammar-compilation latency.

In short

  • Structured outputs constrain a model's own final reply into a schema-valid object with no function invoked, while tool calling produces an intermediate instruction for the calling application to execute — a single agent turn can need either mechanism alone or both at once, since neither one substitutes for the other.
  • OpenAI's json_schema strict mode disallows a root-level anyOf and any default value, Anthropic's output_config.format supports anyOf and $ref but not recursion, and Gemini splits into an OpenAPI-subset responseSchema for 1.5/2.0 versus a full-JSON-Schema responseJsonSchema for 2.5 and later — porting one schema across all three requires checking each provider's subset rather than assuming portability.
  • A schema-valid guarantee under strict mode is bypassed by two conditions that a 200 status code does not rule out: a stop_reason of max_tokens truncating the object mid-generation, or a stop_reason of refusal overriding the schema entirely with a safety response.
  • Self-hosted inference stacks enforce structured output at the token-sampling layer rather than through a provider flag — vLLM and SGLang expose this as a guided_json parameter compiled by XGrammar, and llama.cpp compiles a GBNF grammar directly.
  • The first request against a new or changed JSON Schema on Anthropic and self-hosted grammar backends is measurably slower than subsequent requests because the schema has to compile into a grammar before generation starts, which a warm-up call issued at deploy time avoids exposing to a real user.

Part of the Agent Guardrails and the AI Agent Reliability Playbook guide.


Structured outputs and tool calling sit side by side in one summary table inside the agent reliability in production pillar. This article expands only the structured-outputs half into a full decision guide: a framework for picking a schema-constrained reply over a function call, the per-provider schema subsets shown in runnable code, and both failure modes that survive even under a strict-mode guarantee. It does not re-derive tool-calling's own failure-mode table, BFCL scoring, or parallel-call ordering — see tool-calling contracts for AI agents for that half of the picture.

When should you reach for structured outputs instead of a tool call?

Reach for structured outputs when the task ends with the model's own answer in machine-parseable form, and reach for tool calling when the task requires your application to execute an external action. The distinction is about what happens after the model responds, not about how complex the output schema is. Sentiment-tag a support transcript into {"sentiment": "frustrated", "priority": 2} and nothing outside the model has to run — the object itself is the deliverable, so structured outputs are the right mechanism. Look up a customer's subscription tier and cancel a pending renewal, and the model's output has to name a function and its arguments for your application code to execute — that is a tool call, not a final answer.

Neither mechanism substitutes for the other: a single agent turn can need one alone, both in sequence, or both inside the same call depending on what the provider allows. Three task shapes make this concrete:

Whether a given provider lets you request both mechanisms in the same call, rather than as two sequential requests, is a compatibility detail that varies by provider — covered in the FAQ below — and is worth checking before you design a contract that assumes one call does both.

What schema subset does each provider actually support?

Each provider supports a different subset of JSON Schema under its strict-mode guarantee, and the differences are large enough that a schema portable across all of them has to be written to the narrowest common subset, not the richest one any single provider allows. As of August 2026, five schema-subset comparisons matter in practice: OpenAI, Anthropic, Gemini's two schema modes, and the two dominant self-hosted patterns.

OpenAI: response_format with json_schema and strict: true

OpenAI's strict mode enforces two structural rules on every object node: additionalProperties must be false, and every declared property must be listed as required — model an optional field as a nullable type union rather than leaving it out of required. A root-level anyOf is rejected, and including a default value anywhere in the schema makes the call fail outright rather than being silently dropped.

{
  "response_format": {
    "type": "json_schema",
    "json_schema": {
      "name": "ticket_classification",
      "strict": true,
      "schema": {
        "type": "object",
        "properties": {
          "category": { "type": "string", "enum": ["billing", "technical", "account"] },
          "confidence": { "type": "number" },
          "escalate": { "type": ["boolean", "null"] }
        },
        "required": ["category", "confidence", "escalate"],
        "additionalProperties": false
      }
    }
  }
}

Supported on gpt-4o (2024-08-06 and later), GPT-4.1, GPT-5, and the o-series; older snapshots fall back to the weaker json_object mode described below. response_format and tools are mutually exclusive within a single call.

Anthropic: output_config.format

Anthropic's native structured outputs shipped in late 2025 and are GA on current models as of August 2026, with no beta header required. The canonical call-time parameter is output_config.format set on messages.create itself — a separate top-level output_format create-parameter exists in some SDK surfaces but is deprecated, so new integrations should target output_config directly rather than copying an older example that uses the deprecated name.

{
  "output_config": {
    "format": {
      "type": "json_schema",
      "schema": {
        "type": "object",
        "properties": {
          "category": { "type": "string" },
          "confidence": { "type": "number" },
          "notes": {
            "anyOf": [
              { "type": "string" },
              { "type": "null" }
            ]
          }
        },
        "required": ["category", "confidence"]
      }
    }
  }
}

Unlike OpenAI, Anthropic's schema support includes anyOf and $ref/$defs, but not recursive schemas, and per-request limits apply to tool count and optional/union parameter counts — check current docs for exact caps rather than assuming last year's limits still hold. This is separate from strict: true on an individual tool definition, which constrains that tool's arguments, not the model's final reply — the two are configured independently even though both live under the Anthropic API.

Gemini: responseSchema versus responseJsonSchema — two different modes, not one

Gemini splits schema support across model generations into two genuinely different parameters, and treating them as interchangeable is the most common integration mistake. responseSchema, used with responseMimeType: "application/json" on Gemini 1.5 and 2.0, accepts only an OpenAPI-based subset of JSON Schema — no anyOf, no $ref:

{
  "generationConfig": {
    "responseMimeType": "application/json",
    "responseSchema": {
      "type": "object",
      "properties": {
        "category": { "type": "string" },
        "confidence": { "type": "number" }
      },
      "required": ["category", "confidence"]
    }
  }
}

Gemini 2.5 and later additionally accept responseJsonSchema, which supports the fuller JSON Schema feature set — anyOf, $ref, and limited recursion — and accepts a schema derived directly from a Pydantic or Zod model:

{
  "generationConfig": {
    "responseMimeType": "application/json",
    "responseJsonSchema": {
      "type": "object",
      "properties": {
        "category": { "type": "string" },
        "notes": { "anyOf": [{ "type": "string" }, { "type": "null" }] }
      },
      "required": ["category"]
    }
  }
}

A schema written for responseJsonSchema's richer feature set is not guaranteed to work unmodified if sent as responseSchema against an older model — check which parameter a given model generation actually accepts before assuming a schema is portable across your own Gemini model pins. Some 2.5 snapshots have also been reported to fail structured output when tool-call messages are present in history, and an overly complex schema can trigger a 400 — shortening property names, reducing enum sizes, or flattening nested arrays are the standard workarounds.

Self-hosted: guided_json (vLLM, SGLang) and grammar (llama.cpp)

There is no hosted-provider flag to set for a self-hosted deployment, so the schema constraint has to be enforced by the serving stack itself, at the point where tokens are sampled. vLLM and SGLang expose a guided_json parameter that takes a JSON Schema directly; both runtimes route it through the XGrammar library by default as of 2025, which compiles the schema into a pushdown automaton with low per-token overhead:

response = client.chat.completions.create(
    model="your-self-hosted-model",
    messages=[{"role": "user", "content": "Classify this support ticket."}],
    extra_body={
        "guided_json": {
            "type": "object",
            "properties": {
                "category": {"type": "string"},
                "confidence": {"type": "number"}
            },
            "required": ["category", "confidence"]
        }
    },
)

llama.cpp instead compiles a GBNF (GGML Backus-Naur Form) grammar, either hand-written or auto-converted from a JSON Schema, into the same kind of token-level constraint via its own grammar parameter — the parameter name and grammar format are specific to llama.cpp and not interchangeable with vLLM's guided_json. Outlines is a third self-hosted option available on the same runtimes, using finite-state-machine masking, though it does not support recursive schemas — XGrammar or llguidance are the alternatives when recursion is required. The full per-provider table this section expands is in structured outputs and JSON mode; the equivalent grammar-based mechanism as it applies to tool-call arguments specifically, rather than a final-answer schema, is covered in tool-calling contracts for AI agents.

Why does a strict schema guarantee still fail in production?

A strict schema guarantee still fails in production because two conditions bypass constrained decoding entirely rather than merely stressing it: the response can be truncated before the object closes, or the model can refuse instead of generating at all. Neither condition is caught by checking the HTTP status code, because both return a normal 200 response — the schema violation is only visible in the stop_reason field, which is why a 200 status must never be treated as proof of a valid object.

Checking stop_reason before you parse anything

A stop_reason of "max_tokens" means generation was cut off mid-object before the schema could close, and a stop_reason of "refusal" means a safety response overrode the schema entirely — both need to be checked as first-class branches before any parsing is attempted:

def get_structured_response(client, request):
    response = client.messages.create(**request)

    if response.stop_reason == "max_tokens":
        raise TruncatedOutputError(
            "Response cut off before schema closed; raise max_tokens or simplify the schema"
        )
    if response.stop_reason == "refusal":
        raise SchemaRefusalError(
            "Model refused instead of generating a schema-valid object"
        )

    # Only now is a 200-status response actually safe to parse.
    return json.loads(response.content[0].text)

Treating these as parse-time exceptions rather than upstream validation failures is the difference between a crash your monitoring never explains and a distinguishable, loggable failure mode your retry-and-repair loop can act on directly.

First-request grammar-compilation latency

As of August 2026, the first request against a new or changed JSON Schema is measurably slower than subsequent requests on Anthropic and on every self-hosted grammar backend, because the schema has to compile into a grammar before generation can start, and a schema change invalidates any cached compilation. A production deploy that only sees this delay on a real user's first request has shipped the mitigation in the wrong place — a warm-up call at deploy time, before traffic arrives, moves the cost off the request path entirely:

def warm_up_schema_cache(client, schema, model):
    """Call once per deploy, per distinct schema, before serving real traffic.
    Absorbs first-request grammar-compilation latency outside the request path."""
    client.messages.create(
        model=model,
        max_tokens=16,
        messages=[{"role": "user", "content": "warm-up: reply with a minimal valid object"}],
        output_config={"format": {"type": "json_schema", "schema": schema}},
    )

The other side of this mitigation is discipline, not code: avoid frequent schema churn in the same way you'd avoid frequent cache-key churn, since each distinct schema version pays this compilation cost again on its own first request regardless of how many times an earlier version of the schema already warmed up.

Where this leaves you

The task shape decides the mechanism: pick structured outputs when the last step is the model's own answer in a fixed shape, and pick tool calling when the last step is an action your application has to execute. Check each provider's actual schema subset before assuming a contract written for one provider ports cleanly to another, since OpenAI's, Anthropic's, and Gemini's two schema modes each draw the line differently. Whichever mechanism you use, treat stop_reason as a required check before parsing and warm up a new schema's grammar before real traffic hits it — a strict-mode guarantee only holds for the requests that actually reach a complete, non-refused generation. For the twelve-discipline reliability stack this decision sits inside, see the agent reliability in production pillar; for the orthogonal question of making the tool-calling side of an agent's output reliable, see tool-calling contracts for AI agents.

Frequently asked questions

Can you use structured outputs and tool calling in the same request?
Yes on some providers and no on others as of August 2026: OpenAI's response_format and tools parameters are mutually exclusive in a single call, so a request needing both a tool invocation and a schema-valid final reply has to run them as two separate calls, while Anthropic's output_config for the final reply and strict: true on a tool definition are independent mechanisms that can be configured together in one request.
Is JSON mode the same as structured outputs?
No, JSON mode (OpenAI's json_object) only asks the model to produce syntactically valid JSON with no schema enforced, so it can still omit required fields, use the wrong type, or add unexpected keys, whereas structured outputs (json_schema with strict: true) constrain the decoder so it physically cannot emit a token that violates the declared schema — OpenAI now treats json_object as a legacy mode and recommends json_schema wherever a model supports it.
Why did my structured output request return invalid JSON even with strict mode on?
A structured output request returns invalid or incomplete JSON despite strict mode when the response was cut off by a stop_reason of max_tokens before the object closed, or when the model issued a safety refusal that carries a stop_reason of refusal instead of a completed generation — both bypass the schema guarantee, so checking the stop reason before attempting to parse the response is a required step, not an optional one.
Does Gemini use the same schema format for every model version?
No, Gemini 1.5 and 2.0 accept only responseSchema, an OpenAPI-based subset of JSON Schema with no anyOf or $ref support, while Gemini 2.5 and later also accept responseJsonSchema, which supports the fuller JSON Schema feature set including anyOf, $ref, and limited recursion — a schema written for one Gemini schema mode is not guaranteed to work unmodified against the other.

#agents #reliability #structured-outputs #json-schema #constrained-decoding #production

Put this corpus inside your own agents

This guide is free and stays free. The reference corpus behind it — machine-readable contracts, verified primary sources, continuously refreshed — is the paid product: a €5 starter key unlocks every premium reference for one agent via API; a €25 corpus license delivers the full corpus as RAG / fine-tuning data with an explicit AI-use grant; the €150 enterprise license adds commercial redistribution rights.

Agents: this page as Markdown · JSON · offers at /api/pricing.json · payment methods at /api/payment.json · single-resource access via HTTP 402 (how that works)