Pydantic AI’s tool system is built around type safety and structured schemas — exactly what you want when reading onchain data, where a misplaced decimal or wrong address type produces silently wrong answers rather than errors. The problem is that the framework ships no blockchain tooling. Connecting to a smart contract means reaching for web3.py, managing an RPC provider, loading ABI files, and handling decimal scaling by hand. None of that has anything to do with the agent you are trying to build.
The faster path: define one @agent.tool_plain that calls evmquery’s REST API. evmquery resolves ABIs automatically, executes a typed expression against the live chain, and returns a decoded human-readable value. The tool stays under 30 lines; the agent can query any contract view function on Ethereum, Base, or BNB Chain from a plain English prompt.
TL;DR
pip install pydantic-ai requests, define an @agent.tool_plain, and POST to https://api.evmquery.com/api/v1/query. Your Pydantic AI agent can then read live USDC balances, ETH prices from Chainlink, or Aave health factors on Ethereum, Base, or BNB — no ABIs, no RPC node, no web3.py. Free tier: 2,000 credits/month.
Why Pydantic AI for agent tooling
Pydantic AI builds on the same validation philosophy as Pydantic itself: your data should be typed, validated, and structured from the edge inward. Tools are Python functions with type-annotated parameters; the framework generates the tool schema from those annotations and validates every model-generated argument before your function executes.
For blockchain agents this matters. A model that hallucinates a malformed address or passes a string where a number is expected should fail fast with a clear error, not silently produce a wrong balance. Pydantic AI’s tool_plain decorator does that automatically — it reads parameter types, builds a JSON schema the model must conform to, and validates the call before your HTTP request is ever made.
There is also structured output. If you want the agent to return a typed object — say, a DeFiPosition model with usdc_balance, eth_price_usd, and health_factor fields — you set result_type=DeFiPosition on the agent and the model is forced to produce output that conforms to that shape. That is useful for building downstream pipelines that expect consistent data, not free-form text.
What you’ll build
A single evmquery_read tool that any Pydantic AI agent can invoke to read live EVM contract state. The tool takes a chain identifier, a short-name-to-address contract map, a CEL expression, and optional context variables. It calls the evmquery REST API and returns the decoded result and block number.
If you would rather query the chain interactively from Claude Desktop or Cursor without writing code, the evmquery MCP server is the faster path. This tool is for Python agents with custom business logic — portfolio monitors, DeFi alerting systems, structured data pipelines.
Setup
pip install pydantic-ai requests
export ANTHROPIC_API_KEY=sk-ant-...
export EVMQUERY_API_KEY=your_evmquery_key
Get a free evmquery key at https://app.evmquery.com/onboarding?plan=free. The free tier covers 2,000 credits per month — roughly 1,000 typical contract reads.
Defining the Pydantic AI evmquery tool
import os
from typing import Any
import requests
from pydantic_ai import Agent
EVMQUERY_API = "https://api.evmquery.com/api/v1/query"
agent = Agent(
"anthropic:claude-sonnet-4-6",
system_prompt=(
"You are a blockchain data assistant. When users ask about token balances, "
"DeFi positions, oracle prices, or any current contract state, call "
"evmquery_read to fetch live on-chain data. Always include the block number "
"so the user knows the result is current."
),
)
@agent.tool_plain
def evmquery_read(
chain: str,
contracts: dict[str, str],
expression: str,
context: dict[str, Any] | None = None,
) -> str:
"""Read live data from an EVM smart contract.
Use for current token balances, DeFi positions, oracle prices, or any
contract view function on Ethereum, Base, or BNB Smart Chain.
Supported chains: evm_ethereum, evm_base, evm_bnb_mainnet.
Do NOT use for historical data, event logs, or off-chain prices.
Args:
chain: Chain identifier — evm_ethereum, evm_base, or evm_bnb_mainnet.
contracts: Mapping of short name to 0x contract address.
Example: {"usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"}
expression: CEL expression to evaluate. Contract names become variables.
Example: "formatUnits(usdc.balanceOf(wallet), usdc.decimals())"
context: Optional runtime values for wallet addresses in the expression.
Pass a list of addresses for multi-wallet expressions.
"""
def _type(v: Any) -> str:
return "list<sol_address>" if isinstance(v, list) else "sol_address"
body: dict[str, Any] = {
"chain": chain,
"schema": {
"contracts": {k: {"address": v} for k, v in contracts.items()},
},
"expression": expression,
}
if context:
body["schema"]["context"] = {k: _type(v) for k, v in context.items()}
body["context"] = context
resp = requests.post(
EVMQUERY_API,
json=body,
headers={"x-api-key": os.environ["EVMQUERY_API_KEY"]},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
return f"{data['result']['value']} (block {data['meta']['blockNumber']})"
A few things worth noting:
@agent.tool_plainvs@agent.tool.tool_plainis for tools that don’t need access to the run context — no dependency injection, no run metadata. It is the right choice here because everything the tool needs (the API key) comes from the environment. Use@agent.toolwith aRunContext[Deps]parameter when you want to pass the key through the agent’s dependency injection system instead.- Type annotations drive the schema. Pydantic AI reads the annotations on
contracts,expression, andcontextto build the JSON schema the model must conform to. Wrong types are rejected before your function runs. - Contract address wrapping. evmquery’s REST API expects each contract entry as
{"address": "0x..."}, not a bare string. The dict comprehension on line 36 handles that conversion, so callers can pass the simpler{"usdc": "0xA0b..."}form. - Type inference for context variables. evmquery needs to know whether a context value is a single address (
sol_address) or a list (list<sol_address>). The_typehelper infers this from the Python value automatically.
Three live recipes
All expressions below were validated against the live chain before publication.
ERC-20 balance check
result = agent.run_sync(
"What is the USDC balance of 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 on Ethereum?"
)
print(result.data)
# The wallet holds 5,567.40 USDC as of block 24,983,270.
Underneath, the model calls evmquery_read with:
{
"chain": "evm_ethereum",
"contracts": {"usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"},
"expression": "formatUnits(usdc.balanceOf(wallet), usdc.decimals())",
"context": {"wallet": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"},
}
# tool returns → "5567.402493 (block 24983270)"
formatUnits reads decimals() from the contract itself, so the scaling is always correct regardless of whether the token uses 6, 8, or 18 decimal places.
Live ETH/USD price via Chainlink
result = agent.run_sync("What is the current ETH price in USD?")
print(result.data)
# ETH is trading at $2,314.77 as of block 24,983,271.
The model calls:
{
"chain": "evm_ethereum",
"contracts": {"eth_usd": "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419"},
"expression": "formatUnits(eth_usd.latestAnswer(), eth_usd.decimals())",
}
# tool returns → "2314.77 (block 24983271)"
No context variables needed — this is a pure contract read. The address is the canonical Chainlink ETH/USD aggregator on Ethereum mainnet. The feed updates every block; there is no caching layer between the model and the live price.
Multi-wallet balance scan
result = agent.run_sync(
"Check USDC balances for these three wallets on Ethereum: "
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045, "
"0xBE0eB53F46cd790Cd13851d5EFf43D12404d33E8, "
"0x40B38765696e3d5d8d9d834D8AaD4bB6e418E489"
)
print(result.data)
# USDC balances (block 24,983,272):
# 0xd8dA…6045 → 5,567.40 USDC
# 0xBE0e…33E8 → 3.00 USDC
# 0x40B3…3E8 → 10.01 USDC
The model passes the list of wallets in context:
{
"chain": "evm_ethereum",
"contracts": {"usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"},
"expression": "wallets.map(w, formatUnits(usdc.balanceOf(w), usdc.decimals()))",
"context": {
"wallets": [
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
"0xBE0eB53F46cd790Cd13851d5EFf43D12404d33E8",
"0x40B38765696e3d5d8d9d834D8AaD4bB6e418E489",
]
},
}
# tool returns → "[5567.402493, 3.0, 10.005273] (block 24983272)"
The _type helper detects a Python list and declares the context type as list<sol_address>. The CEL map macro iterates over all three wallets in one RPC round-trip — not three separate tool calls.
Structured results: reading a DeFi position
Pydantic AI’s result_type parameter constrains the agent to return a typed object. This is useful when the output feeds into downstream code rather than a chat interface.
from pydantic import BaseModel
from pydantic_ai import Agent
class DeFiSnapshot(BaseModel):
usdc_balance: float
eth_price_usd: float
block_number: str
snapshot_agent = Agent(
"anthropic:claude-sonnet-4-6",
result_type=DeFiSnapshot,
system_prompt=(
"Fetch live data for the wallet provided and return a structured snapshot. "
"Use evmquery_read for all on-chain reads."
),
)
# Register the same tool on this agent
snapshot_agent.tool_plain(evmquery_read)
result = snapshot_agent.run_sync(
"Snapshot wallet 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 on Ethereum."
)
print(result.data)
# DeFiSnapshot(usdc_balance=5567.402493, eth_price_usd=2314.77, block_number='24983271')
print(result.data.usdc_balance)
# 5567.402493
The model makes two tool calls — one for the USDC balance and one for the ETH price — then assembles the result into the DeFiSnapshot shape. If it tries to return a field that doesn’t match the type annotation, Pydantic AI rejects the response and asks the model to try again.
Aave health factor and infinite health
For wallets with no active borrow position, Aave’s healthFactor returns the maximum uint256 value divided by 1e18 — roughly 1.16e59. This represents infinite health (no debt), not an error. Document this threshold in your system prompt so the model describes it correctly rather than reporting a confusing large number.
# Aave health factor read
result = agent.run_sync(
"What is the Aave v3 health factor for 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 "
"on Ethereum?"
)
# Tool call:
# chain: evm_ethereum
# contracts: {"aave": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2"}
# expression: "formatUnits(aave.getUserAccountData(wallet).healthFactor, 18)"
# context: {"wallet": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"}
# → "1.1579208923731619e+59 (block 24983271)"
# Model response: "This wallet has no active borrow position — health factor is effectively infinite."
Developers building DeFi tooling and monitoring pipelines can find more expression patterns, including reserve data reads and protocol-level aggregations, in the developer resources. If you are focused on AI-native workflows, the AI users page covers the REST tool and MCP surfaces side by side.
Pydantic AI tool vs MCP: picking the right surface
| Pydantic AI tool (this post) | MCP server | |
|---|---|---|
| Language | Python | Any MCP client |
| Setup | ~30 lines in your agent file | Paste one JSON config block |
| Type safety | Full — Pydantic validates every call | Protocol-level only |
| Structured output | Yes — result_type enforces shape | Not natively |
| Multi-tool agents | Yes, composable with other tools | Limited to MCP surface |
| Best for | Production agents, typed pipelines | Claude Desktop, Cursor, VS Code |
If you are building a production Python agent with custom business logic, the @agent.tool_plain approach gives full control over the schema, type validation, error handling, and result formatting. If you want to query the chain interactively from your AI IDE without writing code, the evmquery MCP server guide gets you there in under five minutes.
Next steps
- Set up the evmquery MCP server in Claude Desktop and Cursor — no code required
- Add a live EVM tool to LangChain in Python — same REST integration, different framework
- Monitor Aave health factors with a Python polling script
- Browse the evmquery REST API docs for multi-wallet macros, list filtering, and the full CEL expression reference