Skip to content

Documentation navigation

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:

Python
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:

ApproachWhen to UseRuntime behavior
invoke(..., structured_output=Model) or .structured_output(Model)Request a typed answer for this invocationCompiles or promotes a model constructor and forces it as the final tool
final_tool + force_final_toolName a reusable final tool and author its dependenciesForces the registered final tool; does not itself set an output schema

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

Python
# 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:

  1. Compile the model as a final constructor, or promote an existing tool for that model.
  2. Carry the compiled schema and force the final tool for the invocation.
  3. 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

Python
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:

Python
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:

Python
try:
    response = agent.structured_output(SentimentAnalysis).invoke(messages)
except ValueError as exc:
    print(str(exc))
    raise

The 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

  1. Mark a Pydantic model as final_tool=True
  2. Invoke with force_final_tool=True
  3. The orchestrator executes tools as needed, then calls the final tool

#Basic Usage

Python
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:

Python
agent.add_tool(Report, name='report', final_tool=True)

#Model Requirements

#Use Descriptive Fields

The LLM uses field descriptions to populate values:

Python
@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

Python
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

Python
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:

Python
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:

Python
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:

Python
# 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

Python
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:

Python
@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

Python
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

Python
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

Python
# Good
summary: str = Field(..., description='A 2-3 sentence executive summary')

# Less helpful
summary: str = Field(...)

#2. Use Appropriate Types

Python
# 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

Python
# 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

Python
@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:

Python
@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:

Python
@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

Plain text
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

Feature.structured_output()final_tool + force_final_tool
ExecutionRequired tools, dependencies and schema handlingRequired tools and dependencies
Model callsDepends on required work and schema handlingDepends on required work
Tool executionTools execute as neededTools execute as needed
Swarm supportNot supportedFully supported
Use caseInvocation-local typed answerReusable named final output
Model registrationCompiled or promoted as a forced final constructorRegistered as final_tool=True

#Example: Same Task, Different Approaches

Task: Analyze customer feedback

Python
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