Structured Output
Validate responses against a schema and handle structured-output errors.
#Structured Output Guide
By default an agent replies with free-form text. Structured output requests a Pydantic answer model your code can read from response.result. The SDK validates the returned fields; invalid output raises a validation error. Model and connection failures still need handling, and a valid shape does not prove the facts are correct.
This guide covers the two ways to do that and when to reach for each.
#Start with a tool and an answer model
Register your typed functions in Agent(..., tools=[...]), then request the answer model for the invocation. No bound decorator is needed for this route:
import os
from maivn import Agent
from pydantic import BaseModel
def lookup_order(order_id: str) -> dict[str, str]:
"""Return the synthetic order status for ORD-1042."""
if order_id != 'ORD-1042':
return {'error': 'Unknown order.'}
return {'order_id': order_id, 'status': 'in transit'}
class OrderStatus(BaseModel):
order_id: str
status: str
agent = Agent(
name='Order Assistant',
api_key=os.environ['MAIVN_API_KEY'],
system_prompt='Use lookup_order and answer from its result.',
tools=[lookup_order],
)
response = agent.invoke('Where is order ORD-1042?', structured_output=OrderStatus)
status = response.result
print(status.order_id, status.status)This snippet reads an exported runtime key explicitly. See the Quickstart for installation and execution. The builder form agent.structured_output(OrderStatus).invoke(...) is also available. Explicitly named final tools remain useful for stable output names and dependency metadata, including the runnable parcel demo's Studio selection of status_card.
#Overview
The mAIvn SDK provides two approaches for structured output:
The inline keyword and builder use the same execution path. Required tools, dependencies, argument filling, schema repair, routing, privacy checks, events and usage accounting still run. Ordinary Agent calls have no Swarm roster to plan.
Root schema-bearing requests skip automatic memory recall and final-tool presentation prose. Caller-supplied context, attached documents, explicit skills and authorized bound resources remain available; attachment registration and post-run persistence still follow the memory policy. Default fire_trigger availability is suppressed; an explicit system-tool allowlist still governs it. Forcing a final tool without an output schema retains ordinary recall and presentation behavior.
#Quick Comparison
# Approach 1: request a typed result for this invocation
response = agent.structured_output(SentimentAnalysis).invoke(
[HumanMessage(content='Analyze this text')]
)
# Approach 2: force a reusable registered final tool
response = agent.invoke(
[HumanMessage(content='Fetch data and create report')],
force_final_tool=True,
)#Approach 1: Inline Keyword or .structured_output() Builder
Use this when you want the invocation's result validated as a Pydantic model.
#How It Works
The inline keyword and .structured_output() builder:
- Compile the model as a final constructor, or promote an existing tool for that model.
- Carry the compiled schema and force the final tool for the invocation.
- Execute required tool work and return a typed
response.result.
This may require several model calls. The selective recall and presentation policy does not guarantee one call or a particular latency.
#Basic Usage
from pydantic import BaseModel, Field
from maivn import Agent
from maivn.messages import HumanMessage
class SentimentAnalysis(BaseModel):
"""Sentiment analysis result."""
sentiment: str = Field(..., description='positive, negative, or neutral')
confidence: float = Field(..., ge=0, le=1, description='Confidence score')
key_phrases: list[str] = Field(..., description='Important phrases')
agent = Agent(name='analyzer', api_key='...')
# Request a typed result
response = agent.structured_output(SentimentAnalysis).invoke(
[HumanMessage(content='Analyze: "I absolutely love this product!"')]
)#Builder Options
The .structured_output() builder returns an invocation builder with these options:
response = agent.structured_output(MyModel).invoke(
messages=[HumanMessage(content='...')],
model='balanced', # 'fast', 'balanced', or 'max' (omit for the default)
reasoning='minimal', # 'minimal', 'low', 'medium', 'high'
thread_id='...', # For multi-turn conversations
)#When to Use
- Invocation-local output: Request a model without naming a reusable final tool
- Direct extraction: Text extraction, classification, summarization
- Tool workflows: Required tools and dependencies remain executable
- Single-agent tasks: When you don't need Swarm coordination
#Limitations
- No Swarm support: Cannot use
.structured_output()with Swarms - Required work remains: Tools, argument filling and schema repair can require multiple turns
#Troubleshooting Validation Errors
Structured output returns a InvokeResponse, with the typed object on response.result. If the returned payload does not match your Pydantic model, the SDK raises ValueError with a message identifying the model. The underlying Pydantic validation error is available as exc.__cause__, and a normal traceback shows the validation details before the SDK error.
Catch the SDK error at your application boundary:
try:
response = agent.structured_output(SentimentAnalysis).invoke(messages)
except ValueError as exc:
print(str(exc))
raiseThe most common fix is to return the model fields directly at response.result, not wrapped under an extra key such as payload, output, or an agent/tool name.
#Approach 2: Registered final_tool Pattern
Use this when you need a stable final-tool name or authored dependency metadata. Both approaches can execute tools before producing the result.
#How It Works
- Mark a Pydantic model as
final_tool=True - Invoke with
force_final_tool=True - The orchestrator executes tools as needed, then calls the final tool
#Basic Usage
from pydantic import BaseModel, Field
from maivn import Agent
from maivn.messages import HumanMessage
agent = Agent(
name='report_agent',
api_key='...',
system_prompt='Generate detailed reports.',
)
@agent.toolify(final_tool=True)
class Report(BaseModel):
"""A structured report."""
title: str = Field(..., description='Report title')
summary: str = Field(..., description='Executive summary')
findings: list[str] = Field(..., description='Key findings')
recommendation: str = Field(..., description='Primary recommendation')
response = agent.invoke(
[HumanMessage(content='Analyze Q4 sales data')],
force_final_tool=True,
)You can also register the final model imperatively:
agent.add_tool(Report, name='report', final_tool=True)#Model Requirements
#Use Descriptive Fields
The LLM uses field descriptions to populate values:
@agent.toolify(final_tool=True)
class UserProfile(BaseModel):
name: str = Field(..., description='Full name of the user')
email: str = Field(..., description='Email address')
role: str = Field(..., description='Job role or title')
department: str = Field(..., description='Department name')#Support Complex Types
from typing import Literal
from datetime import datetime
@agent.toolify(final_tool=True)
class DetailedReport(BaseModel):
title: str
status: Literal['draft', 'review', 'final']
created_at: datetime
tags: list[str]
metrics: dict[str, float]
priority: int = Field(..., ge=1, le=5) # 1-5 validation#Nested Models
class Author(BaseModel):
name: str
email: str
class Section(BaseModel):
title: str
content: str
@agent.toolify(final_tool=True)
class Document(BaseModel):
title: str
author: Author
sections: list[Section]
word_count: int#force_final_tool
Requires final-tool routing for a single invocation. Execution can still fail:
response = agent.invoke(
[HumanMessage(content='Create a report')],
force_final_tool=True,
)Registering a final_tool=True model does not make it mandatory by itself. This keeps final tools available for the planner without forcing them into every turn. If the agent is a typed handoff boundary and should always return its final-tool schema, set the constructor-level default:
agent = Agent(
name='report_agent',
api_key='...',
force_final_tool=True,
)
agent.add_tool(Report, name='report', final_tool=True, always_execute=True)The constructor flag is especially useful in Swarms. Nested member invocations honor agent.force_final_tool=True, so the swarm receives a typed handoff even when the member is deployed indirectly by the orchestrator. A per-call invoke(..., force_final_tool=True) still forces just that one invocation.
#When to Use final_tool Pattern
- Tool workflows: When tools must execute before generating output
- Data fetching: Fetch data with tools, then structure the result
- Multi-agent: Swarm workflows that need structured final output
- Dependencies: When final output depends on tool results
#Validation
#Multiple Final Tools
A scope can register multiple final tools in v2, provided their names are unique. When a run produces multiple final-tool calls, the first successful call supplies its result. See final tool selection.
A swarm has separate ownership checks: it cannot combine a swarm-level final tool with a member marked use_as_final_output=True. If multiple members own final tools, designate exactly one final-output member.
#Mixing always_execute and final_tool
always_execute and final_tool are orthogonal — one controls frequency, the other controls role. They can coexist on the same tool or on different tools in the same scope:
# Valid: an audit tool always runs, and a separate tool finalizes output.
@agent.toolify(always_execute=True)
def logger() -> dict: ...
@agent.toolify(final_tool=True)
class Report(BaseModel): ...When a top-level registered tool has always_execute=True, the planner must schedule it at least once. This deterministic enforcement applies to regular agent calls and to nested Swarm member calls. Nested Pydantic models referenced by a top-level model field are schema definitions only; they do not inherit always_execute or final_tool and are not scheduled as separate tools.
#force_final_tool Requires a Final Tool
agent = Agent(name='test', api_key='...')
@agent.toolify() # No final_tool
def my_tool() -> dict: ...
# This raises an error
response = agent.invoke(
[...],
force_final_tool=True, # Error: no final tool defined!
)#Combining with Dependencies
Final tools can have dependencies:
@agent.toolify(description='Fetch sales data')
def fetch_sales() -> dict:
return {'sales': [...]}
@agent.toolify(description='Calculate metrics')
@depends_on_tool(fetch_sales, arg_name='sales_data')
def calculate_metrics(sales_data: dict) -> dict:
return {'revenue': 100000, 'growth': 15}
@agent.toolify(final_tool=True)
@depends_on_tool(calculate_metrics, arg_name='metrics')
class SalesReport(BaseModel):
"""Final sales report."""
metrics: dict = Field(..., description='Injected calculated metrics')
total_revenue: float = Field(..., description='Total revenue')
growth_rate: float = Field(..., description='Growth percentage')
summary: str = Field(..., description='Executive summary')The dependency chain executes in order, then the final tool produces structured output.
#Swarm Final Output
In a swarm, you can either:
#Option 1: Swarm-Level Final Tool
swarm = Swarm(name='team', agents=[agent1, agent2])
@swarm.toolify(final_tool=True)
class TeamReport(BaseModel):
summary: str
contributions: list[str]#Option 2: Final Output Agent
writer = Agent(
name='writer',
api_key='...',
use_as_final_output=True, # This agent produces final output
)
swarm = Swarm(name='team', agents=[researcher, writer])
response = swarm.invoke(
[...],
force_final_tool=True,
)Only ONE of these can be used per swarm.
#Best Practices
#1. Descriptive Field Descriptions
# Good
summary: str = Field(..., description='A 2-3 sentence executive summary')
# Less helpful
summary: str = Field(...)#2. Use Appropriate Types
# Good: specific types
score: float = Field(..., ge=0, le=100)
status: Literal['pending', 'complete', 'failed']
tags: list[str]
# Avoid: overly generic
data: dict # Too vague#3. Keep Models Focused
# Good: focused model
@agent.toolify(final_tool=True)
class WeatherReport(BaseModel):
city: str
temperature: int
conditions: str
# Avoid: kitchen sink model
@agent.toolify(final_tool=True)
class Everything(BaseModel):
weather: dict
news: dict
stocks: dict
calendar: dict#4. Add Model Docstrings
@agent.toolify(final_tool=True)
class AnalysisReport(BaseModel):
"""Comprehensive analysis report with findings and recommendations.
Used for quarterly business reviews and strategic planning.
"""
title: str
findings: list[str]
recommendation: str#Error Handling
#Missing Required Fields
If the LLM fails to populate a required field, Pydantic validation fails:
@agent.toolify(final_tool=True)
class Strict(BaseModel):
required_field: str # Must be provided
optional_field: str | None = None # Can be None#Default Values
Provide defaults for optional fields:
@agent.toolify(final_tool=True)
class Flexible(BaseModel):
title: str # Required
tags: list[str] = [] # Defaults to empty list
priority: int = 5 # Defaults to 5#Choosing the Right Approach
#Decision Guide
Do you need a reusable named final tool or a Swarm final output?
├── YES → Register final_tool and use force_final_tool
│ - Stable name and authored dependency metadata
│ - Supports Swarm final output
│
└── NO → Use invoke(..., structured_output=Model) or the builder
- Typed result for this invocation
- Required tools and dependencies still execute#Detailed Comparison
#Example: Same Task, Different Approaches
Task: Analyze customer feedback
class FeedbackAnalysis(BaseModel):
sentiment: str
topics: list[str]
urgency: int
# Approach 1: invocation-local typed answer
response = agent.structured_output(FeedbackAnalysis).invoke(
[HumanMessage(content='Analyze: "Your service is terrible..."')]
)
# Approach 2: reusable final tool with dependencies
@agent.toolify(description='Fetch customer feedback from database')
def fetch_feedback(customer_id: str) -> dict:
return {'feedback': '...'}
@agent.toolify(final_tool=True)
@depends_on_tool(fetch_feedback, arg_name='data')
class FeedbackAnalysis(BaseModel):
data: dict
sentiment: str
topics: list[str]
urgency: int
response = agent.invoke(
[HumanMessage(content='Analyze feedback for customer 123')],
force_final_tool=True,
)#Next steps
- Tools Guide - Model tool basics
- Agent API -
invoke()andstructured_output()methods - Swarm API - Swarm final output