Structured Output
Structured output lets agents and chat models return predictable data instead of prose that your application has to parse. In BeamWeaver, structured output is represented as JSON-shaped Elixir maps validated against JSON Schema-shaped maps.
Use structured output in two places:
-
Stable agent modules use
response_schema/2withBeamWeaver.Schema. Runtime-generated agents can use:response_format. Both return:structured_responsein the final agent state. -
Chat models use
BeamWeaver.Models.with_structured_output/3or provider:response_formatoptions and return parsed data in message metadata.
Agent Usage
Define the response shape with BeamWeaver.Schema and pass it to response_schema/2.
defmodule MyApp.ContactInfo do
use BeamWeaver.Schema
title "contact_info"
description "Contact information for a person."
strict true
field :name, :string, required: true, description: "The person's name"
field :email, :string, required: true, description: "The email address"
field :phone, :string, required: true, description: "The phone number"
end
defmodule MyApp.ContactAgent do
use BeamWeaver.Agent
model BeamWeaver.Models.init_chat_model!("openai:gpt-5.4-mini")
response_schema MyApp.ContactInfo, name: "contact_info", strategy: :auto
end
{:ok, state} =
MyApp.ContactAgent.invoke(%{
messages: [
BeamWeaver.Core.Message.user(
"Extract contact info: John Doe, [email protected], (555) 123-4567"
)
]
})
state.structured_response
# %{
# "name" => "John Doe",
# "email" => "[email protected]",
# "phone" => "(555) 123-4567"
# }
Runtime-built agents use :response_format because they are assembled from runtime data. Given the same schema module:
alias BeamWeaver.Agent
alias BeamWeaver.Agent.StructuredOutput
alias BeamWeaver.Core.Message
{:ok, agent} =
Agent.build(
model: BeamWeaver.Models.init_chat_model!("openai:gpt-5.4-mini"),
tools: [],
response_format: StructuredOutput.auto(MyApp.ContactInfo.json_schema())
)
{:ok, state} =
Agent.invoke(agent, %{
messages: [Message.user("Extract contact info from the text.")]
})
state.structured_response
Response Formats
BeamWeaver response formats are built with BeamWeaver.Agent.StructuredOutput.
| Format | Use |
|---|---|
StructuredOutput.tool(schema, opts)
| Ask the model to call a synthetic structured-output tool. |
StructuredOutput.provider(schema, opts)
| Ask the provider API to enforce structured output natively. |
StructuredOutput.auto(schema, opts)
| Auto-select provider strategy when the model profile supports structured output safely, otherwise tool strategy. |
nil
| No structured output request. |
Unlike the Python docs, BeamWeaver does not expose generic ToolStrategy[SchemaT] or ProviderStrategy[SchemaT] classes. The functions above return Elixir strategy structs.
In module DSL, the normal form is:
response_schema MyApp.ContactInfo,
name: "contact_info",
strategy: :auto
Strategy values are atoms. Public Elixir config rejects string aliases such as "auto" and "tool"; use :auto, :tool, or :provider.
Provider Strategy
Provider strategy uses the model provider's native structured-output API. OpenAI, Anthropic, Google, DeepSeek, xAI, and Z.ai provider adapters accept structured output request options, and agent auto-selection uses the model profile's :structured_output capability.
DeepSeek Chat exposes JSON object mode, so BeamWeaver injects the schema as an explicit instruction and validates the returned object locally. DeepSeek Responses exposes native JSON Schema format for both V4 Flash and V4 Pro.
response_schema MyApp.ContactInfo,
name: "contact_info",
strategy: :provider,
strict: true
strict: true is passed to providers that support strict JSON Schema adherence. Provider support varies; unsupported providers may ignore strictness, emulate it with instructions plus local validation, or reject the request.
For OpenAI response formats, BeamWeaver normalizes strict schemas before sending the request: object schemas are closed with additionalProperties: false, every declared property is listed in required, optional properties become nullable, and unsupported validation/composition keywords are removed. A free-form object such as %{"type" => "object"} becomes a closed empty object in strict mode; model genuinely dynamic key/value payloads as arrays of entries or use non-strict/application validation when arbitrary keys are required.
Provider-native structured output and active tool calling are not equally reliable across providers. When normal tools are active, BeamWeaver avoids provider-native structured output unless the model profile explicitly marks the combination as supported with structured_output_with_tools: true. Otherwise the effective strategy is the tool strategy, preserving the same schema name.
Structured-output strategy selection is profile-backed. BeamWeaver evaluates:
-
structured_output -
structured_output_with_tools -
structured_output_max_schema_bytes -
structured_output_max_schema_properties
Strategy behavior is deterministic:
-
:toolalways uses the structured-output tool strategy. -
:provideruses provider-native structured output only when the model profile says it is safe; otherwise it falls back to tool strategy and records the fallback reason in trace metadata. -
:autochooses provider-native only when the profile supports it, active tools are safe, and schema size/property limits pass; otherwise it chooses tool strategy.
Trace metadata includes:
-
structured_output_requested_strategy -
structured_output_effective_strategy -
structured_output_fallback_reason -
structured_output_schema_bytes -
structured_output_schema_properties
For specialists that need tools and must return structured data, prefer a research pass followed by a tool-free structured generation pass. In agent modules this is configured with execution_mode: :research_then_generate.
Raw JSON Schema maps are compatibility and dynamic-construction inputs. For example, a runtime builder can receive a schema map from configuration and wrap it explicitly:
response_format StructuredOutput.auto(@contact_schema)
Provider strategy returns the parsed structured value in state.structured_response:
{:ok, state} =
MyApp.ProviderAgent.invoke(%{
messages: [BeamWeaver.Core.Message.user("Extract the contact info.")]
})
state.structured_response
Tool Strategy
Tool strategy works with models that support tool calling. BeamWeaver registers a synthetic tool whose name comes from the schema title.
defmodule MyApp.ProductReview do
use BeamWeaver.Schema
title "product_review"
description "Analysis of a product review."
strict true
field :rating, {:nullable, :integer}, description: "Rating from 1 to 5"
field :sentiment, :string, required: true, enum: ["positive", "negative"]
field :key_points, {:array, :string}, required: true
end
response_schema MyApp.ProductReview,
name: "product_review",
strategy: :tool
When the model calls the synthetic tool, BeamWeaver validates the tool arguments, stores the parsed map in :structured_response, and adds a tool message to the conversation history:
%{
structured_response: %{
"rating" => 5,
"sentiment" => "positive",
"key_points" => ["fast shipping", "expensive"]
},
messages: messages
}
When normal tools are present alongside the structured-output tool, ordinary tool calls continue the agent loop. The run ends with :structured_response only after the model calls one of the structured-output pseudo tools. BeamWeaver marks those pseudo tools with structured-output metadata for tracing and asks providers to choose a tool when the tool strategy is active.
Customize the tool message content when you want the model-visible observation to be stable and short:
response_schema MyApp.ReviewSchema,
name: "product_review",
strategy: :tool,
tool_message_content: "Structured review captured."
Multiple Schema Choices
For tool strategy, use oneOf to let the model choose one structured-output shape. BeamWeaver creates one synthetic tool per variant.
@schema %{
"oneOf" => [
%{
"title" => "contact_info",
"type" => "object",
"required" => ["name", "email"],
"properties" => %{
"name" => %{"type" => "string"},
"email" => %{"type" => "string"}
}
},
%{
"title" => "event_details",
"type" => "object",
"required" => ["event_name", "date"],
"properties" => %{
"event_name" => %{"type" => "string"},
"date" => %{"type" => "string"}
}
}
]
}
response_schema @schema, name: "contact_or_event", strategy: :tool
If the model calls more than one structured-output tool for a single response, BeamWeaver returns a :multiple_structured_outputs error or creates an error tool message, depending on the :handle_errors setting.
Error Handling
Tool strategy accepts :handle_errors:
response_schema MyApp.ReviewSchema,
name: "product_review",
strategy: :tool,
handle_errors: true
Supported values:
| Value | Behavior |
|---|---|
true
| Convert structured-output errors into tool messages. |
false
| Return the tagged error to the caller or middleware. |
"message"
| Use this message as the error tool message. |
:structured_output_validation_error
| Handle only this tagged error type. |
[:structured_output_validation_error, :multiple_structured_outputs]
| Handle only these tagged error types. |
fn error -> message end
|
Build a custom message from %BeamWeaver.Core.Error{}.
|
To make the model retry after validation failures, add BeamWeaver.Agent.Middleware.StructuredOutputRetry and let structured-output errors propagate from the strategy:
defmodule MyApp.RetryingReviewAgent do
use BeamWeaver.Agent
alias BeamWeaver.Agent.Middleware.StructuredOutputRetry
alias BeamWeaver.Agent.StructuredOutput
@review_schema %{
"title" => "product_review",
"type" => "object",
"required" => ["sentiment"],
"properties" => %{
"sentiment" => %{"type" => "string"},
"key_points" => %{"type" => "array"}
}
}
model BeamWeaver.Models.init_chat_model!("openai:gpt-5.4-mini")
middleware do
use StructuredOutputRetry, max_retries: 2
end
response_schema @review_schema,
name: "product_review",
strategy: :tool,
handle_errors: false
end
Customize retry feedback with a fixed message or function:
middleware do
use StructuredOutputRetry,
max_retries: 2,
feedback: fn error -> "Fix the structured output: #{error.message}" end
end
Direct Model Usage
Use BeamWeaver.Models.with_structured_output/3 when you want structured output from a standalone chat model without an agent loop.
alias BeamWeaver.Core.ChatModel
alias BeamWeaver.Core.Message
alias BeamWeaver.Models
contact_schema = %{
"title" => "contact_info",
"type" => "object",
"required" => ["name", "email"],
"properties" => %{
"name" => %{"type" => "string"},
"email" => %{"type" => "string"}
}
}
model =
BeamWeaver.Models.init_chat_model!("openai:gpt-5.4-mini")
|> Models.with_structured_output(contact_schema)
{:ok, response} =
ChatModel.invoke(model, [
Message.user("Extract contact info: John Doe, [email protected]")
])
response.metadata.structured_response
Provider adapters also accept direct structured-output options:
{:ok, response} =
ChatModel.invoke(model, [Message.user("Return JSON for this contact.")],
response_format: %{
name: "contact_info",
schema: contact_schema,
strict: true
}
)
response.metadata["parsed"]
OpenAI and xAI Responses and Chat Completions use JSON Schema response formats. Anthropic uses output_config.format for structured output. Google maps schemas to Gemini generation config. Z.ai uses JSON object mode with BeamWeaver-injected schema instructions and local validation. Structured-output parse errors include the provider finish/status reason, clipped content preview, metadata, and usage details so truncation and tool-call-only responses can be diagnosed without logging the full provider payload. See the
OpenAI
,
Anthropic
,
Google
,
xAI
,
Z.ai
, and
Models
guides for provider-specific request details.