| PolarSPARC |
Quick Primer on Pydantic AI
| Bhaskar S | *UPDATED*08/30/2026 |
Overview
Pydantic AI is an open-source, Python based agentic framework that aims to make it easy for application developers to build production grade Gen AI apps that can interface with different LLM models.
The following are the list of some of the core classes from Pydantic AI framework:
Agent :: is the primary abstraction for interacting with the LLM models and is a container for the following:
LLM Model - a model associated with the agent
Instructions - a set of instructions provided by a user to guide the model. They are similar to System Prompt(s), but with a difference - these instructions are not included in the message history
Structured Output Type - model results returned in the specified structured data format
Model Setting(s) - optional model settings to control the model behavior
Model :: refers to the class that implements a portable and vendor agnostic API to make requests to the underying LLM model(s). For interacting with LLM model(s) that are served via Ollama , one can use the OllamaModel or the OpenAIChatModel class. The Model class uses an instance of the appropriate Provider class (OllamaProvider or OpenAIProvider), which encapsulates various parameters such as, the endpoint URL, API key, etc., to connect and make requests to the underlying LLM model
Funtion Tools :: are mechanisms for the LLM model(s) to retrieve information from external sources in order to generate responses to user prompts. They are useful for augmenting on what the LLM model(s) can do. The following are the two ways to register a Python function as a Function Tool:
@agent.tool_plain decorator for Python function(s) that do not need access to any context information at runtime
@agent.tool decorator for Python function(s) that need access to all the runtime contextual information
Instead of the tool annotation, one can also use the Tool wrapper class
Hooks :: allows one to intercept and modify the Agent behavior at different stages, such as, before or after the model requests, before or after the tool calls, etc.
Capabilities :: allows one to create a reusable, composable unit of Agent behavior using a combination of Instructions, Tools, Hooks, etc.
The main intent of this article is NOT to be exhaustive, but a primer for one to get started quickly.
Installation and Setup
The installation and setup will be on a Ubuntu 24.04 LTS based Linux desktop. Ensure that Ollama is installed and setup on the desktop (see instructions).
In addition, ensure that the Python 3.1x programming language as well as the Jupyter Notebook package is installed and setup on the desktop.
Assuming that the ip address on the Linux desktop is 192.168.1.25, start the Ollama platform by executing the following command in the terminal window:
$ docker run --rm --name ollama -e OLLAMA_KEEP_ALIVE=-1 -e OLLAMA_FLASH_ATTENTION=1 -e OLLAMA_CONTEXT_LENGTH=8192 -p 192.168.1.25:11434:11434 -v $HOME/.ollama:/root/.ollama ollama/ollama:0.33.2
If the linux desktop has Nvidia GPU with decent amount of VRAM (at least 16 GB) and has been enabled for use with docker (see instructions), then execute the following command instead to start Ollama:
$ docker run --rm --name ollama --gpus=all -e OLLAMA_KEEP_ALIVE=-1 -e OLLAMA_FLASH_ATTENTION=1 -e OLLAMA_CONTEXT_LENGTH=8192 -p 192.168.1.25:11434:11434 -v $HOME/.ollama:/root/.ollama ollama/ollama:0.33.2
For the LLM model, we will be using the Gemma 4 4B model.
Open a new terminal window and execute the following docker command to download the LLM model:
$ docker exec -it ollama ollama run gemma4:e4b
To install the necessary Python modules for this primer, execute the following command:
$ pip install dotenv openai pydantic pydantic-ai pydantic-ai-slim
This completes all the installation and setup for the Pydantic AI hands-on demonstrations.
Hands-on with Pydantic AI
Create a file called .env with the following environment variables defined:
LLM_MAX_RETRIES=5 LLM_TEMPERATURE=0.2 OLLAMA_MODEL='gemma4:e4b' OLLAMA_BASE_URL='http://192.168.1.25:11434/v1'
To load the environment variables and assign them to corresponding Python variables, execute the following code snippet:
from dotenv import load_dotenv, find_dotenv
import os
load_dotenv(find_dotenv())
llm_max_retries = int(os.getenv('LLM_MAX_RETRIES'))
llm_temperature = float(os.getenv('LLM_TEMPERATURE'))
llm_model = os.getenv('OLLAMA_MODEL')
ollama_base_url = os.getenv('OLLAMA_BASE_URL')
ollama_api_key = 'ollama'
To initialize an instance of the Provider class for the Ollama running on the host URL, execute the following code snippet:
from pydantic_ai.providers.ollama import OllamaProvider llm_provider = OllamaProvider(base_url=ollama_base_url)
To initialize an instance of the Model class for the Ollama running the gemma4:e4b model, execute the following code snippet:
from pydantic_ai.models.ollama import OllamaModel ollama_model = OllamaModel(model_name=llm_model, provider=llm_provider)
Note that one can also initialize an instance of the Provider class for Ollama running on the host URL using the OpenAI semantics. To do so, execute the following code snippet:
from pydantic_ai.providers.openai import OpenAIProvider llm_provider = OpenAIProvider(base_url=ollama_base_url, api_key=ollama_api_key)
Using the OpenAI semantics, to initialize an instance of the Model class for Ollama running the gemma4:e4b model, execute the following code snippet:
from pydantic_ai.models.openai import OpenAIChatModel ollama_model = OpenAIChatModel(model_name=ollama_model, provider=llm_provider)
To initialize an instance of the ModelSetttings class for the Ollama platform running the desired LLM model, execute the following code snippet:
from pydantic_ai.settings import ModelSettings llm_model_settings = ModelSettings(temperature=llm_temperature)
The temperature parameter in the above code is a value that is between 0.0 and 1.0. It determines whether the output from the LLM model should be more "creative" or be more "predictive". A higher value means more "creative" and a lower value means more "predictive".
To initialize an instance of the Agent class with a custom system prompt, execute the following code snippet:
from pydantic_ai import Agent
ai_agent = Agent(ollama_model,
retries=llm_max_retries,
system_prompt=('You are a helpful mathematical genius',
'Your final answer should be in the form of a python dictionary'
)
)
To test the agent with a user prompt, execute the following code snippet:
response = ai_agent.run_sync('Which of the two numbers is bigger - 9.01234 vs 9.01234',
model_settings=llm_model_settings)
print(response.output)
Executing the above Python code would generate the following typical output:
```json
{
"comparison": "The two numbers are equal.",
"details": {
"number_1": 9.01234,
"number_2": 9.01234,
"result": "9.01234 = 9.01234"
}
}
```
For the next demonstration on structured output, we will create a Pydantic class that will be used to capture basic geographic information of a country. To create our Pydantic class, execute the following code snippet:
from pydantic import BaseModel, Field class GeographicInfo(BaseModel): country: str = Field(description="Name of the Country") capital: str = Field(description="Name of the Capital City") population: int = Field(description="Population of the country in billions") land_area: int = Field(description="Land Area of the country in square miles") list_of_rivers: list = Field(description="List of top 5 rivers in the country")
Executing the above Python code generates no output.
To initialize an new instance of the Agent class for the structured output, execute the following code snippet:
ai_agent2 = Agent(ollama_model,
retries=llm_max_retries,
output_type=GeographicInfo,
model_settings=llm_model_settings)
To test the agent with a user prompt, execute the following code snippet:
response2 = ai_agent2.run_sync('Get the Geographic Info for India')
print(response2.output)
Executing the above Python code would generate the following typical output:
AgentRunResult(output=GeographicInfo(country='India', capital='New Delhi', population=1, land_area=1653827, list_of_rivers=['Ganges', 'Yamuna', 'Godavari', 'Krishna', 'Narmada']))
The next demonstration is on the use of tools. To initialize an new instance of the Agent class for invoking specific tools, execute the following code snippet:
ai_agent3 = Agent(ollama_model,
retries=llm_max_retries,
model_settings=llm_model_settings,
system_prompt='Execute the appropriate tool to complete the specific task')
Executing the above Python code generates no output.
For this demonstration, we will make use of two custom tools using the agent decorator (method annotation) - one to compute the simple interest for a year and the second to compute the compound interest for a year.
To create the two tools that can be invoked by the agent, execute the following code snippet:
@ai_agent3.tool_plain
async def yearly_simple_interest(principal: float, rate:float) -> float:
"""Tool to compute simple interest rate for a year."""
print(f'Simple interest -> Principal: {principal}, Rate: {rate}')
return principal * rate / 100.00
@ai_agent3.tool_plain
async def yearly_compound_interest(principal: float, rate:float) -> float:
"""Tool to compute compound interest rate for a year."""
print(f'Compound interest -> Principal: {principal}, Rate: {rate}')
return principal * (1 + rate / 100.0)
Notice the use of the @ai_agent3.tool_plain decorator on the above functions.
Executing the above Python code generates no output.
To test the agent with a user prompt for computing simple interest, execute the following code snippet:
response3 = ai_agent3.run_sync('find the simple interest for a principal of 1000 at a rate of 4.25')
print(response3.new_messages())
print(response3.output)
print(response3.usage)
Executing the above Python code would generate the following typical output:
[ModelRequest(parts=[SystemPromptPart(content='Execute the appropriate tool to complete the specific task', timestamp=datetime.datetime(2026, 8, 30, 17, 41, 49, 995811, tzinfo=datetime.timezone.utc)), UserPromptPart(content='find the simple interest for a principal of 1000 at a rate of 4.25', timestamp=datetime.datetime(2026, 8, 30, 17, 41, 49, 995818, tzinfo=datetime.timezone.utc))], timestamp=datetime.datetime(2026, 8, 30, 17, 41, 49, 996310, tzinfo=datetime.timezone.utc), run_id='01a053c3-6729-725a-9bfd-ab721e9cb36a', conversation_id='01a053c3-6729-725a-9bfd-ab73a4d1107d'), ModelResponse(parts=[ThinkingPart(content='1. **Analyze the Request:** The user wants to find the "simple interest" for a principal of 1000 at a rate of 4.25.\n\n2. **Examine Available Tools:**\n * `yearly_simple_interest`: Computes simple interest rate for a year. Requires `principal` and `rate`.\n * `yearly_compound_interest`: Computes compound interest rate for a year. Requires `principal` and `rate`.\n\n3. **Determine the Correct Tool:** The request explicitly asks for "simple interest," making `yearly_simple_interest` the appropriate tool.\n\n4. **Identify Parameters:**\n * Principal: 1000\n * Rate: 4.25\n\n5. **Construct the Tool Call:**\n * Function: `yearly_simple_interest`\n * Arguments: `principal=1000`, `rate=4.25`\n\n6. **Final Output Generation:** Generate the tool call in the required JSON format.', id='reasoning', provider_name='ollama'), ToolCallPart(tool_name='yearly_simple_interest', args='{"principal":1000,"rate":4.25}', tool_call_id='call_27mf6av3')], usage=RequestUsage(input_tokens=169, output_tokens=245), model_name='gemma4:e4b', timestamp=datetime.datetime(2026, 8, 30, 17, 41, 53, 571853, tzinfo=datetime.timezone.utc), provider_name='ollama', provider_url='http://192.168.1.25:11434/v1/', provider_details={'finish_reason': 'tool_calls', 'timestamp': datetime.datetime(2026, 8, 30, 17, 41, 53, tzinfo=TzInfo(0))}, provider_response_id='chatcmpl-506', finish_reason='tool_call', run_id='01a053c3-6729-725a-9bfd-ab721e9cb36a', conversation_id='01a053c3-6729-725a-9bfd-ab73a4d1107d'), ModelRequest(parts=[ToolReturnPart(tool_name='yearly_simple_interest', content=42.5, tool_call_id='call_27mf6av3', timestamp=datetime.datetime(2026, 8, 30, 17, 41, 53, 572984, tzinfo=datetime.timezone.utc))], timestamp=datetime.datetime(2026, 8, 30, 17, 41, 53, 573307, tzinfo=datetime.timezone.utc), run_id='01a053c3-6729-725a-9bfd-ab721e9cb36a', conversation_id='01a053c3-6729-725a-9bfd-ab73a4d1107d'), ModelResponse(parts=[ThinkingPart(content='The user asked to find the simple interest for a principal of 1000 at a rate of 4.25.\nI used the `yearly_simple_interest` tool with `principal=1000` and `rate=4.25`.\nThe tool returned a value of `42.5`.\nI should now present this result clearly to the user.', id='reasoning', provider_name='ollama'), TextPart(content='The simple interest for a principal of 1000 at a rate of 4.25 is **42.5**.')], usage=RequestUsage(input_tokens=436, output_tokens=112), model_name='gemma4:e4b', timestamp=datetime.datetime(2026, 8, 30, 17, 41, 55, 261979, tzinfo=datetime.timezone.utc), provider_name='ollama', provider_url='http://192.168.1.25:11434/v1/', provider_details={'finish_reason': 'stop', 'timestamp': datetime.datetime(2026, 8, 30, 17, 41, 55, tzinfo=TzInfo(0))}, provider_response_id='chatcmpl-789', finish_reason='stop', run_id='01a053c3-6729-725a-9bfd-ab721e9cb36a', conversation_id='01a053c3-6729-725a-9bfd-ab73a4d1107d')]
The simple interest for a principal of 1000 at a rate of 4.25 is **42.5**.
RunUsage(input_tokens=605, output_tokens=357, requests=2, tool_calls=1)
The method new_messages() on the agent response only contains messages from this agent run. Also, the property usage on the agent response contains the token usage.
For the next demonstration, we will make will create a custom tool, which will execute shell or system commands. This custom tool will also expect some runtime context (via a custom class).
To create the custom class representing the context, execute the following code snippet:
from pydantic import BaseModel class UserContext(BaseModel): user_id: str is_admin: bool
Next, to create the custom shell execution tool that can be invoked by the agent, execute the following code snippet:
from pydantic_ai import Agent, RunContext
ai_agent4 = Agent(ollama_model,
retries=llm_max_retries,
model_settings=llm_model_settings,
system_prompt='Execute the appropriate tool to complete the specific task',
deps_type=UserContext)
@ai_agent4.tool
async def execute_shell_command(ctx: RunContext[UserContext], command: str) -> str:
"""Tool to execute shell or system commands"""
print(f'Preparing to execute shell command: {command}')
print(f'User ID: {ctx.deps.user_id}')
print(f'Is Admin: {ctx.deps.is_admin}')
if not ctx.deps.is_admin:
return 'Error: Only admins can execute shell commands'
try:
result = subprocess.run(command, shell=True, check=True, text=True, capture_output=True)
if result.returncode != 0:
return f'Error executing shell command - {command}'
return result.stdout
except subprocess.CalledProcessError as e:
print(e)
return f'Error executing shell command - {command}'
Notice the use of the @ai_agent4.tool decorator on the above function. This decorator expects the first method argument to be of type RunContext.
To test the agent with a user prompt for finding the ip address on the system with a user run context, execute the following code snippet:
response5 = ai_agent4.run_sync(user_prompt=('Determine the ip address on the system by executing the appropriate linux command',
'The final answer should be in the form of a python dictionary'),
deps=UserContext(user_id='alice', is_admin=True))
print(response5.output)
Executing the above Python code would generate the following typical output:
Preparing to execute shell command: ip a
User ID: alice
Is Admin: True
```python
{
"system_ip_address": "192.168.1.25"
}
```
Once again, to test the agent with a user prompt for finding the ip address on the system with different user run context, execute the following code snippet:
response6 = ai_agent4.run_sync('find the host name as well as the maximum memory available on the system',
deps=UserContext(user_id='bob', is_admin=False))
print(response6.output)
Executing the above Python code would generate the following typical output:
Preparing to execute shell command: hostname && free -h
User ID: bob
Is Admin: False
I encountered a permission error when trying to execute the system commands. It appears that the execution environment requires administrative privileges (like `sudo`) to run commands such as `hostname` and `free`.
Please run the following commands yourself in your terminal to get the required information:
1. **To find the host name:**
```bash
hostname
```
2. **To find the memory information (including total/maximum):**
```bash
free -h
```
If you run these commands and provide the output, I can help you interpret the results.
For the next demonstration, we will make will create the custom tool for executing shell or system commands without using the agent decorator (method annotation), but instead use the Tool class. This custom tool also expects a runtime context.
To create and register the custom tool with the agent, execute the following code snippet:
from pydantic_ai import Agent, RunContext, Tool
async def shell_command_v2(ctx: RunContext[UserContext], command: str) -> str:
"""Tool to execute shell or system commands"""
print(f'[V2] Preparing to execute shell command: {command}')
print(f'[V2] User ID: {ctx.deps.user_id}')
print(f'[V2] Is Admin: {ctx.deps.is_admin}')
if not ctx.deps.is_admin:
return '[V2] Error: Only admins can execute shell commands'
try:
result = subprocess.run(command, shell=True, check=True, text=True, capture_output=True)
if result.returncode != 0:
return f'[V2] Error executing shell command - {command}'
return result.stdout
except subprocess.CalledProcessError as e:
print(e)
return f'[V2] Error executing shell command - {command}'
shell_tool = Tool(name='shell_command',
description='Tool to execute shell or system commands',
function=shell_command_v2,
takes_ctx=True)
ai_agent5 = Agent(ollama_model,
retries=llm_max_retries,
model_settings=llm_model_settings,
system_prompt='Execute the appropriate tool(s) to complete the specific task',
deps_type=UserContext,
tools=[shell_tool])
Now, to test the agent with a user prompt to determine both the hostname and maximum memory in the system with a run context, execute the following code snippet:
response7 = ai_agent4.run_sync('Get the host name as well as the maximum memory available on the system in json format',
deps=UserContext(user_id='alice', is_admin=True))
print(response7.output)
Executing the above Python code would generate the following typical output:
[V2] Preparing to execute shell command: hostname
[V2] User ID: alice
[V2] Is Admin: True
[V2] Preparing to execute shell command: free -m | grep Mem | awk '{print $2}'
[V2] User ID: alice
[V2] Is Admin: True
```json
{
"hostname": "polarsparc",
"max_memory_available_mb": 64227
}
```
Once again, to test the agent with a user prompt to determine both the hostname and maximum memory in the system with another run context, execute the following code snippet:
response8 = ai_agent5.run_sync(user_prompt=('Get the primary ip address on the system by executing the appropriate linux command',
'The final answer should be in the form of a python dictionary'),
deps=UserContext(user_id='bob', is_admin=False))
print(response8.output)
Executing the above Python code would generate the following typical output:
[V2] Preparing to execute shell command: ip a
[V2] User ID: bob
[V2] Is Admin: False
```python
{
"error": "Permission Denied",
"message": "The execution environment restricted access to shell commands necessary to retrieve the system's primary IP address.",
"suggested_action": "Please ensure the execution environment has the necessary permissions (e.g., running as root or with elevated privileges) to run network commands like 'ip addr show'."
}
```
This concludes the hands-on demonstration on using the Pydantic AI framework !!!
References