smolagents ships with web search, code execution, and a Python interpreter. What it doesn’t include is any surface for reading live smart contract data. If your agent needs the current USDC balance of a wallet, the ETH price from Chainlink, or an Aave health factor, you hit a wall fast: raw RPC calls, ABI files, web3.py boilerplate. None of that fits cleanly into a CodeAgent workflow.
The fix is a single Tool subclass that wraps evmquery’s REST API. One class, one forward method, and every smolagents agent you build gets live EVM reads on Ethereum, Base, or BNB with zero ABI wrangling.
TL;DR
pip install smolagents requests, subclass Tool, and POST to https://api.evmquery.com/api/v1/query. Any smolagents CodeAgent or ToolCallingAgent can then read live USDC balances, ETH prices from Chainlink, or Aave health factors on Ethereum, Base, or BNB. No ABIs, no RPC node. Free tier: 2,000 credits/month.
How smolagents tools work
In smolagents, every tool is a class that subclasses Tool. The class carries four things the framework needs to expose the capability to an LLM:
name— a unique slug the agent uses when calling the tooldescription— the prose the model reads when deciding whether to invoke itinputs— a dict of parameter names to{"type": ..., "description": ...}objectsoutput_type— a Pydantic-compatible type string ("string","object", etc.)
The logic lives in forward(**kwargs). When a CodeAgent decides to use the tool, it generates Python code that calls tool.forward(...) directly. When a ToolCallingAgent uses it, the framework dispatches the JSON tool call internally. Both paths hit the same forward method.
The description is the most load-bearing field. It is the policy the model reads when routing a user request. Write it as a scoped contract: what the tool is for, what chains it supports, and what it should not be used for. A tight description reduces misrouted calls more reliably than any prompt engineering downstream.
What you will build
A single EvmQueryTool class that any smolagents agent can call to read live state from any EVM-compatible contract. The tool accepts a chain identifier, a named contract map, and a CEL expression. evmquery resolves the ABI, executes the expression on the live chain, and returns a decoded value. You write no ABI code, no eth_call plumbing, no decoder.
For a side-by-side overview of the surfaces evmquery exposes for AI agents, the AI users page covers MCP and REST together.
Setup
pip install smolagents requests
pip install 'smolagents[litellm]' # add if you want Anthropic or OpenAI models
export EVMQUERY_API_KEY=your_key_here
export ANTHROPIC_API_KEY=sk-ant-... # if using Claude via LiteLLM
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 EvmQueryTool
import os
from typing import Optional
import requests
from smolagents import Tool
class EvmQueryTool(Tool):
name = "evmquery_read"
description = (
"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 or event logs."
)
inputs = {
"chain": {
"type": "string",
"description": "Chain identifier: evm_ethereum, evm_base, or evm_bnb_mainnet.",
},
"contracts": {
"type": "object",
"description": (
"Mapping of short name to 0x contract address. "
'Example: {"usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"}'
),
},
"expression": {
"type": "string",
"description": (
"CEL expression to evaluate. Contract names become callable variables. "
'Example: "formatUnits(usdc.balanceOf(wallet), usdc.decimals())"'
),
},
"context": {
"type": "object",
"description": (
"Runtime values for wallet addresses in the expression. "
"Pass an empty dict when the expression needs no external parameters. "
'Example: {"wallet": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"}'
),
},
}
output_type = "string"
def forward(
self,
chain: str,
contracts: dict,
expression: str,
context: Optional[dict] = None,
) -> str:
def _type(v) -> str:
return "list<sol_address>" if isinstance(v, list) else "sol_address"
body: dict = {
"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(
"https://api.evmquery.com/api/v1/query",
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']})"
Three things worth noting:
- Contracts as objects. The evmquery REST API requires each contract entry as
{"address": "0x..."}, not a bare address string. The dict comprehension{k: {"address": v} for k, v in contracts.items()}handles that conversion automatically, so the model can pass a clean{"usdc": "0xA0b..."}map without knowing the API’s shape. - Automatic type inference. 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, keeping that detail out of the agent’s prompt. resp.raise_for_status()converts HTTP errors into Python exceptions that smolagents can catch and surface. The agent receives the error and can retry with a reformulated call or escalate.
Three live recipes
All expressions below were validated against the live chain before publication.
ERC-20 balance check
tool = EvmQueryTool()
result = tool.forward(
chain="evm_ethereum",
contracts={"usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"},
expression="formatUnits(usdc.balanceOf(wallet), usdc.decimals())",
context={"wallet": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"},
)
# → "120133.627066 (block 25033475)"
formatUnits reads decimals() directly from the contract, so the scaling is always correct regardless of whether the token uses 6 (USDC), 8, or 18 decimal places.
ETH/USD price via Chainlink
result = tool.forward(
chain="evm_ethereum",
contracts={"eth_usd": "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419"},
expression="formatUnits(eth_usd.latestAnswer(), eth_usd.decimals())",
context={},
)
# → "2380.16626074 (block 25033475)"
No wallet parameter needed here — this is a pure contract read. The address is the canonical Chainlink ETH/USD aggregator on Ethereum mainnet. The same pattern works for any Chainlink price feed: swap the address, keep the expression.
Aave health factor
result = tool.forward(
chain="evm_ethereum",
contracts={"aave": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2"},
expression="formatUnits(aave.getUserAccountData(wallet).healthFactor, 18)",
context={"wallet": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"},
)
# → "1.157920892373162e+59 (block 25033475)"
What the large number means
A healthFactor of 1.16e+59 is not an error. For wallets with no active borrow position, Aave returns the maximum uint256 value divided by 1e18, which represents infinite health. A value below 1.0 indicates liquidation risk.
getUserAccountData returns a struct. evmquery exposes struct fields via dot notation, so you can read totalCollateralBase, totalDebtBase, or any other field from the same call. One expression, one round-trip.
Wiring into a CodeAgent
With the tool defined, assembling an agent takes four lines:
from smolagents import CodeAgent, LiteLLMModel
model = LiteLLMModel(model_id="anthropic/claude-sonnet-4-6")
agent = CodeAgent(
tools=[EvmQueryTool()],
model=model,
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 data before answering. Always include the "
"block number in your reply so the user knows the result is current."
),
)
Invoke it with a natural language prompt:
response = agent.run(
"What is the USDC balance of 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 on Ethereum?"
)
print(response)
# The wallet holds 120,133.63 USDC as of block 25,033,475.
The model recognizes the query requires live data, generates Python code that calls evmquery_read, receives the result, and incorporates it into a natural language reply. The routing comes from the tool description alone.
Multiple tools
Pass additional tools alongside EvmQueryTool() in the tools list. The model routes to the right one based on descriptions. Pair it with a portfolio calculator, a notification sender, or a web search tool. Each tool stays cleanly scoped.
CodeAgent vs ToolCallingAgent
smolagents ships two agent types. The right choice depends on your use case:
CodeAgent | ToolCallingAgent | |
|---|---|---|
| How it calls tools | Generates and runs Python code | Emits JSON tool-call messages |
| Best for | Multi-step reasoning, batch reads, chained logic | Single-step lookups, structured pipelines |
| EVM use case | Portfolio summaries, multi-wallet scans, conditional alerts | Direct wallet queries, webhook-triggered reads |
| Model requirement | Any model that can write Python | Any model that supports tool calling |
CodeAgent is the stronger choice for blockchain workflows that involve multiple contract reads, arithmetic across results, or iteration over a list of wallets. The model can write a loop, call the tool per entry, and aggregate results in one agent step.
ToolCallingAgent is faster for single lookups where the model retrieves a value and returns it directly. Both accept the same tools=[EvmQueryTool()] argument. The tool class itself is identical in both cases.
Developers building production DeFi tooling can find additional expression patterns on the developer resources page.
Next steps
- Set up the evmquery MCP server in Claude Desktop and Cursor for interactive chain reads without writing code
- Build a LangChain EVM blockchain tool in Python if your stack uses LangChain
- Monitor Aave health factors with a Python polling script for production alert workflows
- Browse the evmquery REST API docs for multi-wallet macros, list filtering, and the full CEL expression reference