The AI agent framework landscape is evolving rapidly, and while many frameworks exist, few provide the type safety and developer ergonomics that production applications demand. Pydantic AI, created by the team behind the Pydantic validation library, brings that “FastAPI feeling” to AI agent development. This blog will help you get started with the basics of building Pydantic AI applications with proper type checking, structured outputs, and multi-agent orchestration.
The Type-Safe Philosophy
Pydantic AI takes a different approach to agent development by putting type safety at the forefront. Every input, output, and dependency is validated through Pydantic models, catching errors at development time rather than runtime. This means your IDE knows precisely what your agents expect and return, making development more reliable.
While the framework defaults to using OpenAI models, it’s designed to be completely model-agnostic. You can easily switch between specific providers such as OpenAI, LLM gateway providers like Groq for blazing-fast inference, Claude for complex reasoning, Gemini for multimodal tasks, or run everything locally with Ollama. This flexibility lets you optimize for speed, cost, or capability without changing your agent logic.
Getting Started: Your First Type-Safe Agent
Creating an agent in Pydantic AI emphasizes structure and type safety from the start. Here’s a simple agent:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 | from pydantic_ai import Agent from pydantic import BaseModel, Field import os from dotenv import load_dotenv load_dotenv() # Define the output structure class ResearchSummary(BaseModel): """A structured summary of web research results.""" topic: str = Field( ..., description="The primary topic of the research query." ) report: str = Field( ..., description="A detailed report on the topic." ) source_confidence_score: float = Field( ..., description="A score from 0.0 to 1.0 indicating confidence in the factual accuracy of the results, based on source quality." ) def main(): # Check for API key api_key = os.getenv("OPENAI_API_KEY") if not api_key: raise ValueError( "OPENAI_API_KEY environment variable is required. " "Set it with: export OPENAI_API_KEY='your-api-key-here'" ) # Create an agent with Groq for speed agent = Agent( "openai:gpt-4o-mini", output_type=ResearchSummary, system_prompt=( """You are a research assistant and your job is to research the topic provided and return a detailed report on the topic.""" ) ) # Run the agent result = agent.run_sync("What are the latest developments in quantum computing?") # unpack the result data = result.output print(f"Topic: {data.topic}") print(f"Report: {data.report}") print(f"Confidence: {data.source_confidence_score}") if __name__ == "__main__": main() |
The beauty is that your output is always validated against the ResearchSummary model, regardless of which LLM you use.
Adding Tools with Dependency Injection
Pydantic AI’s tool system uses dependency injection to provide clean, testable code. The type system ensures that if you get the dependency type wrong, you’ll know immediately during development, not in production. Here is an example of using a built-in tool as well as a local function as a tool.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 | import os import random from dotenv import load_dotenv from pydantic import BaseModel, Field from pydantic_ai import Agent, WebSearchTool from pydantic_ai.models.openai import OpenAIResponsesModel load_dotenv() # Define the output structure class ResearchSummary(BaseModel): """A structured summary of web research results.""" topic: str = Field( ..., description="The primary topic of the research query." ) report: str = Field( ..., description="A detailed report on the topic." ) source_confidence_score: float = Field( ..., description="A score from 0.0 to 1.0 indicating confidence in the factual accuracy of the results, based on source quality." ) roll_dice: float = Field( ..., description="Output from rolling a dice" ) # Create the agent at module level so decorators can reference it agent = Agent( OpenAIResponsesModel("gpt-4o-mini"), output_type=ResearchSummary, system_prompt=( """You are a research assistant and your job is to research the topic provided and return a detailed report on the topic. You must also roll a dice using the roll_dice tool and include the result in your response.""" ), builtin_tools=[WebSearchTool()] ) @agent.tool_plain def roll_dice() -> str: """Roll a die""" return str(random.randint(1, 6)) def main(): # Check for API key api_key = os.getenv("OPENAI_API_KEY") if not api_key: raise ValueError( "OPENAI_API_KEY environment variable is required. " "Set it with: export OPENAI_API_KEY='your-api-key-here'" ) # Run the agent result = agent.run_sync("What are the latest developments in quantum computing?") # unpack the result data = result.output print(f"Topic: {data.topic}") print(f"Report: {data.report}") print(f"Confidence: {data.source_confidence_score}") print(f"Dice Roll: {data.roll_dice}") if __name__ == "__main__": main() |
Integrating with MCP Servers
Pydantic AI provides native support for the Model Context Protocol (MCP), allowing agents to connect to thousands of pre-built tool servers. This eliminates the need to write custom integrations for standard services.
Here’s how to setup your own MCP server that calls an external API for a random Dad joke
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 | # Pydantic AI response object class JokeResponse(BaseModel): """Structured response containing a dad joke.""" joke: str = Field( description="The random dad joke fetched from the API" ) analysis: str = Field( description="A brief humorous analysis or rating of the joke" ) # MCP server class DadJokeMCPServer: """MCP Server that provides a tool to fetch random dad jokes.""" def __init__(self): self.server = Server("dad-joke-server") self.setup_handlers() def setup_handlers(self): """Set up MCP server handlers.""" @self.server.list_tools() async def list_tools() -> list[Tool]: """List available tools - just the get_random_dad_joke tool.""" return [ Tool( name="get_random_dad_joke", description="Fetches a random dad joke from icanhazdadjoke.com API", inputSchema={ "type": "object", "properties": {}, "required": [] } ) ] @self.server.call_tool() async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: """Handle tool calls - fetch dad joke from API.""" if name != "get_random_dad_joke": raise ValueError(f"Unknown tool: {name}") # Fetch joke from icanhazdadjoke.com async with httpx.AsyncClient() as client: response = await client.get( "https://icanhazdadjoke.com/", headers={"Accept": "application/json"} ) response.raise_for_status() joke_data = response.json() return [ TextContent( type="text", text=joke_data.get("joke", "No joke available") ) ] async def run(self): """Run the MCP server.""" async with stdio_server() as (read_stream, write_stream): await self.server.run( read_stream, write_stream, self.server.create_initialization_options() ) |
The MCP integration handles all the complexity of tool discovery, parameter validation, and error handling, letting you focus on your application logic. See the complete code in Git repo linked at the bottom of the blog.
Multi-Agent
Pydantic AI provides flexible patterns for building multi-agent systems, from simple delegation to complex orchestration. Each pattern serves different architectural needs while maintaining type safety throughout. The three patterns are…
- Pattern 1: Agent Delegation via Tools – The most common pattern involves agents delegating specialized tasks to other agents through tools. The parent agent maintains control and decides when to consult specialists.
- Pattern 2: Sequential Hand-off with Context – For workflows where each agent completely handles its phase before passing to the next, use programmatic hand-off with proper context transfer.
- Pattern 3: Dynamic Routing with Output Functions – When an agent needs to dynamically route to different specialists based on the conversation, use output functions for a complete hand-off.
For this blog, we will cover pattern2, which is very deterministic but not an Agentic approach. In a truly Agentic approach, we would simply provide the LLM with the Agents, the tools, and the goal. We then leave it to the Agentic flow to decide how to sequence the work and get towards the goal. Most of us starting with Agents will probably start with single or multi-agent deterministic flows and later try out actual Agentic flows.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | # Create Research Planner Agent planner_agent = Agent( modelName, system_prompt=( "You are a research planner. Your job is to break down complex research " "questions into specific, actionable research tasks. For each task, provide " "a clear description of what needs to be researched." ) ) # Create Research Executor Agent executor_agent = Agent( modelName, system_prompt=( "You are a research executor. Your job is to perform thorough research " "on the topics provided. Provide detailed, well-structured information " "based on your knowledge. Be comprehensive and cite key points." ) ) # research synthesizer agent synthesizer_agent = Agent( modelName, system_prompt=( "You are a research synthesizer. Your job is to synthesize the research " "findings from the research executor and provide a concise summary of the findings." ) ) print(f"Research Question: {research_question}") print("-" * 60) # Step 1: Planner breaks down the research question print("Step 1: Planning research tasks...") planning_result = planner_agent.run_sync( f"Break down this research question into 3-4 specific research tasks: {research_question}" ) print(f"Planner Output:\n{planning_result.output}\n") # Step 2: Executor performs research on each task print("Step 2: Executing research...") execution_result = executor_agent.run_sync( f"Based on this research plan, provide a comprehensive research report:\n\n" f"Research Question: {research_question}\n\n" f"Research Plan:\n{planning_result.output}\n\n" f"Please provide a detailed research report covering all the planned topics." ) print(f"Research Report:\n{execution_result.output}\n") # Step 3: Synthesize findings (optional - using executor as synthesizer) print("Step 3: Synthesizing findings...") synthesis_result = executor_agent.run_sync( f"Based on this research report, provide a concise summary with key takeaways:\n\n" f"{execution_result.output}" ) print(f"Summary:\n{synthesis_result.output}\n") |
This pattern works well for pipeline-style workflows where each stage has clear inputs and outputs, and you need complete control over the flow.
Looking Forward
The complete Git code is at https://github.com/thomasma/pydanticai
Pydantic AI, by combining type safety, structured outputs, and flexible model support, enables us to build AI applications with the same confidence as they build traditional software. The framework’s integration with MCP opens up a vast ecosystem of tools while maintaining type safety throughout.
For developers building production AI systems, Pydantic AI offers a balance of safety and flexibility. The type system catches errors early, dependency injection enables clean testing, and the model-agnostic design prevents vendor lock-in. Start with simple agents, add structure through Pydantic models, and scale to multi-agent systems as your needs grow.