The OpenAI Agents SDK turns a Python function into an agent tool with one decorator. The model still arrives blind to the chain: ask it for the current USDC balance of any wallet or the live ETH/USD price and it will pull from training data rather than read the actual value. Onchain state changes every block. Training snapshots are months stale.
One @function_tool wrapper fixes that. POST a chain identifier, a named contract map, and a CEL expression to evmquery, and the agent gets back a live decoded value from the chain. No ABIs, no RPC node, no web3 boilerplate. The whole tool fits in 25 lines.
TL;DR
pip install openai-agents requests, decorate a function with @function_tool, and POST to https://api.evmquery.com/api/v1/query. Your agent reads live USDC balances, ETH prices from Chainlink, and Aave health factors on Ethereum, Base, or BNB Smart Chain. Free tier: 2,000 credits/month.
The OpenAI Agents SDK in one paragraph
OpenAI released the Agents SDK in 2025 as a minimal Python framework for agent loops. An Agent holds a model name, a system prompt, and a list of tools. Runner.run() drives the loop: call the model, execute any tool calls it requests, feed the results back, repeat until the model produces a final response. There is no hidden orchestration, no graph to define, and no state machine. The loop is transparent and async by default.
Tools are registered with the @function_tool decorator. It reads your type hints to generate the JSON schema the model receives, and reads your docstring for the tool description. The model uses both when deciding whether and how to invoke the function. A vague docstring produces bad calls. A tight docstring with concrete examples produces correct ones.
Install and configure
pip install openai-agents requests
Two environment variables are required:
OPENAI_API_KEY: your OpenAI keyEVMQUERY_API_KEY: your evmquery key. The free tier gives 2,000 credits per month with no credit card required.
A single contract read costs 2 to 3 credits, so the free tier is enough to prototype comfortably. Grab a key here.
The evmquery tool in 25 lines
import os
import requests
from agents import function_tool
EVMQUERY_URL = "https://api.evmquery.com/api/v1/query"
@function_tool
def query_evm(
chain: str,
contracts: dict[str, str],
expression: str,
) -> str:
"""
Read live state from EVM smart contracts.
chain: Chain to query. One of: evm_ethereum, evm_base, evm_bnb_mainnet.
contracts: Named contract map. Keys become variable names in the expression;
values are the contract's 0x address.
expression: CEL expression to evaluate. Use formatUnits() for decimal scaling.
Embed wallet addresses with solAddress('0x...').
Examples:
formatUnits(usdc.balanceOf(solAddress('0x...')), usdc.decimals())
formatUnits(feed.latestAnswer(), feed.decimals())
"""
resp = requests.post(
EVMQUERY_URL,
headers={"x-api-key": os.environ["EVMQUERY_API_KEY"]},
json={
"chain": chain,
"schema": {"contracts": contracts},
"expression": expression,
},
timeout=30,
)
resp.raise_for_status()
return str(resp.json().get("result", resp.text))
Three things worth unpacking.
The docstring is load-bearing. The model reads it to learn what chain, contracts, and expression mean. The expression examples teach the correct CEL syntax so the model does not have to guess. Without them, the model commonly tries Python-style method calls (usdc.balanceOf(wallet) with a bare string) and gets a type error back. With them, it writes correct expressions on the first attempt.
No ABI needed. evmquery resolves contract ABIs from verified on-chain sources, including Etherscan and Sourcify, and falls back to bytecode signature matching when the source is not available. You name the contract; the resolution is handled server-side.
No proxy headaches. A large fraction of production contracts sit behind EIP-1967 upgradeable proxies. A naive eth_call to a proxy hits the empty fallback. evmquery detects the proxy, resolves the implementation, and decodes against the right ABI automatically. Your expression targets implementation methods directly.
Wire it into an agent
import asyncio
from agents import Agent, Runner
agent = Agent(
name="chain-reader",
model="gpt-4o",
instructions=(
"You have live EVM blockchain access via query_evm. "
"Always call the tool when asked about token balances, DeFi positions, "
"or on-chain prices. Never answer from training data. "
"Key contracts: "
"USDC on Ethereum: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48. "
"Chainlink ETH/USD on Ethereum: 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419. "
"Aave V3 Pool on Ethereum: 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2."
),
tools=[query_evm],
)
async def main() -> None:
result = await Runner.run(
agent, "What is the current ETH price from Chainlink on Ethereum?"
)
print(result.final_output)
asyncio.run(main())
The instructions field does two jobs. It tells the agent when to call the tool (always, not from training data) and it carries the canonical contract addresses so the model does not hallucinate them. Embedding addresses in the system prompt is intentional: letting the model look them up adds a failure mode; hardcoding them removes it.
For production use, pull the contract map from a config file or environment variable rather than a hardcoded string. The agent logic stays the same; the instructions string is just a string.
Three queries that prove it works
ETH/USD from Chainlink
"What is the current ETH price from Chainlink on Ethereum?"
The model calls query_evm with chain: evm_ethereum, contracts: {"feed": "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419"}, and expression: formatUnits(feed.latestAnswer(), feed.decimals()). At time of writing the return value is 2316.74. One round trip, two credits.
USDC balance for a wallet
"What is vitalik.eth's USDC balance on Ethereum?"
The model writes formatUnits(usdc.balanceOf(solAddress('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045')), usdc.decimals()). Returns 5567.40. The solAddress() helper wraps the string in the correct on-chain address type; the model learns this from the docstring example.
Aave V3 health factor
"Is the Aave health factor for 0xd8dA…6045 safe on Ethereum?"
The model writes formatUnits(aave.getUserAccountData(solAddress('0xd8dA...6045')).healthFactor, 18) and calls query_evm.
Large number means no debt
For Aave positions with no active borrowing, healthFactor returns 2^256 / 1e18, approximately 1.16e59. That is not an error. It means no borrowed balance, so no liquidation risk. A value below 1.0 means the position is underwater. Add an explicit note in your instructions so the model surfaces this interpretation to the user rather than printing a confusing raw float.
Multi-wallet scans with the map macro
The evmquery expression language includes a map macro for list operations. For multi-wallet queries, this means one tool call instead of N sequential calls, and one Multicall3 round trip on the node side.
If a user asks for USDC balances across several wallets on Base, the model calls query_evm with:
chain: evm_base
contracts: {"usdc": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"}
expression: [solAddress('0xWallet1'), solAddress('0xWallet2')].map(w, formatUnits(usdc.balanceOf(w), usdc.decimals()))
The result is a JSON array of balances, one per wallet. Credit cost stays flat regardless of wallet count — the reads are batched server-side.
Left to its own devices the model defaults to sequential single calls when given a list of wallets. If wallet count matters for cost or latency, prompt the model explicitly to use the list form. Adding “Batch multi-wallet reads using the list.map() expression pattern” to the instructions is enough.
What the tool cannot do
Three hard constraints to communicate before shipping to users.
No writes. query_evm is read-only by design. evmquery never broadcasts transactions on your behalf. For anything that requires signing, add a separate wallet tool with an explicit user confirmation step.
No event history. The tool reads current state at the block the query lands on. For historical data (“what was this balance six months ago?”), you need an indexer rather than a read layer. The blockchain indexer guide covers when to use each.
No off-chain data. NFT floor prices on marketplaces, token prices on CEXes, and any other data not published to an on-chain feed live outside what query_evm can reach. Adding “Do not invent off-chain data” to instructions ensures the model declines gracefully instead of fabricating a number.
Next steps
- For developers: the REST API this tool wraps is the same engine behind the evmquery MCP server and n8n node. One query language, every integration surface.
- Already using Claude or Cursor? The EVM blockchain MCP server guide is a zero-code alternative to wiring up your own tool.
- Using LangChain instead of the OpenAI SDK? The LangChain EVM blockchain tool post covers the same pattern with
@tooland LangGraph. - Prefer no-code automation? The /for/ai-users page has a checklist for connecting evmquery to Claude Desktop, Cursor, and other clients.