Advanced function calling: schemas and validation
On this page
Advanced function calling: schemas and validation
Function calling is the core mechanism that lets an agent decide when and how to invoke your tools. Declaring a tool is enough for a prototype, but moving to production requires you to constrain calls, validate business values, and handle failures without crashing the agent. That’s what this lesson covers.
JSON schemas and strict mode
The Responses API uses JSON schemas to describe the parameters for each tool, and strict mode guarantees that the model adheres exactly to the schema. Specifically, using an Enum or a Literal shuts the door on invented values.
from agents import function_tool
from pydantic import BaseModel, Field
from typing import Literal
from enum import Enum
class Priority(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class CreateTicket(BaseModel):
title: str = Field(description="Short ticket title (max 100 characters)")
description: str = Field(description="Detailed problem description")
priority: Priority = Field(description="Ticket priority level")
component: Literal["frontend", "backend", "infra", "data"] = Field(
description="Affected technical component"
)
tags: list[str] = Field(
default_factory=list,
description="Optional tags to categorize the ticket"
)
@function_tool
def create_support_ticket(ticket: CreateTicket) -> str:
"""Creates a new technical support ticket."""
# In production: call Jira, Linear, GitHub Issues, etc.
return f"Ticket created: [{ticket.priority.value}] {ticket.title} ({ticket.component})"
With strict mode enabled by default, the model can never send an invalid priority or a component outside the allowed list. You won’t need to write defensive backend code to translate “urgent” or “P1” into a value your ticketing system recognizes.
Custom validation with Pydantic
Type checking stops impossible values, not absurd ones. A fifteen-character IBAN is a perfectly valid string; your business logic rejects it. Pydantic lets you embed these rules directly into the schema.
from pydantic import BaseModel, Field, field_validator
class MoneyTransfer(BaseModel):
source_account: str = Field(description="Source account IBAN")
destination_account: str = Field(description="Destination account IBAN")
amount: float = Field(gt=0, le=50000, description="Amount in euros (max 50,000)")
reason: str = Field(min_length=5, max_length=200, description="Reason for transfer")
@field_validator("source_account", "destination_account")
@classmethod
def validate_iban(cls, v):
if not v.startswith("FR"):
raise ValueError("Only French IBANs are accepted")
if len(v) != 27:
raise ValueError("IBAN must contain 27 characters")
return v
@function_tool
def execute_transfer(transfer: MoneyTransfer) -> str:
"""Executes a bank transfer between two accounts."""
return f"Transfer of €{transfer.amount} initiated from {transfer.source_account[:8]}... to {transfer.destination_account[:8]}..."
The gt=0 and le=50000 constraints on the amount play a similar role on a different level: they make a negative transfer or one exceeding the limit structurally impossible, regardless of how the user phrases the request.
Handling errors in tools
What about when a tool executes but fails? The temptation is to let the exception bubble up; that’s a mistake, because it interrupts the agent loop. Instead, return a clear error message that the model can read and interpret.
import json
from agents import function_tool
@function_tool
def query_external_api(endpoint: str, parameters: dict) -> str:
"""Queries an external API with the specified parameters."""
try:
# In production: real HTTP call
import httpx
response = httpx.get(f"https://api.example.com/{endpoint}", params=parameters, timeout=10)
response.raise_for_status()
return json.dumps(response.json(), ensure_ascii=False)
except httpx.TimeoutException:
return json.dumps({
"error": True,
"message": f"Timeout calling {endpoint}. Retry in a few moments."
})
except httpx.HTTPStatusError as e:
return json.dumps({
"error": True,
"message": f"HTTP error {e.response.status_code} on {endpoint}.",
"details": str(e)
})
except Exception as e:
return json.dumps({
"error": True,
"message": f"Unexpected error: {str(e)}"
})
Each branch formats the message in a language the model understands. On a timeout, the agent will attempt another call; on an HTTP 404 error, it will inform the user or change approach rather than persisting blindly.
Structured output for tools
This constraint applies to the final output as well. With output_type, final_output is no longer text to parse but a typed object, directly usable by the calling code.
from pydantic import BaseModel
from agents import Agent, Runner
class AnalysisResponse(BaseModel):
summary: str
key_points: list[str]
recommendation: str
confidence_score: float
agent = Agent(
name="Analyst",
instructions="Analyze the provided data and produce a structured report.",
model="gpt-5.6-terra",
output_type=AnalysisResponse,
)
result = Runner.run_sync(agent, "Analyze Q1 2026 sales trends.")
# result.final_output is of type AnalysisResponse
print(f"Summary: {result.final_output.summary}")
print(f"Score: {result.final_output.confidence_score}")
Pattern: tools with shared context
One last recurring issue: your tools often need shared state—user session, database connection, API key—that it would be absurd to pass through the model. Context solves this by passing it alongside the conversation.
from dataclasses import dataclass
from agents import Agent, Runner, RunContextWrapper, function_tool
@dataclass
class AppContext:
user_id: str
db_connection: object # Your DB connection
api_key: str
@function_tool
def get_user_profile(ctx: RunContextWrapper[AppContext]) -> str:
"""Retrieves the current user's profile."""
user_id = ctx.context.user_id
# Use ctx.context.db_connection for the query
return f"User profile {user_id}: Premium, active since 2024"
agent = Agent(
name="Personalized Agent",
instructions="You assist the logged-in user.",
tools=[get_user_profile],
)
context = AppContext(user_id="usr_123", db_connection=None, api_key="xxx")
result = Runner.run_sync(agent, "What is my profile?", context=context)
The tool has no business parameters: the user’s identity comes from the context, not the model. This is also a security measure, since the agent cannot view someone else’s profile by changing an argument.
Key takeaways
- Pydantic schemas with enums and Literals guarantee call validity
- Field validators add business validation on top of type checking
- Return JSON error messages instead of raising exceptions in tools
output_typeforces the agent to structure its final response- Shared context (
RunContextWrapper) lets tools access application state