DSPy treats every LLM interaction as a program, not a conversation. You declare what you want with a typed signature ("question -> answer"), add tools via dspy.ReAct, and DSPy handles the reasoning loop. The model traces its steps, selects a tool, processes the result, and delivers a structured answer.
The gap is live EVM blockchain data. DSPy can optimize tool selection, but it cannot invent the current ETH price or a wallet’s USDC balance. Those numbers live on-chain and change every block. An evmquery tool closes that gap in under 25 lines, giving any DSPy ReAct agent access to live EVM contract state without ABIs, RPC nodes, or web3 boilerplate.
TL;DR
pip install dspy requests, define a plain Python function evmquery_read with type hints and a docstring, pass it to dspy.ReAct, and your agent can answer questions about USDC balances, ETH prices, and Aave positions from a natural language prompt. Free tier: 2,000 credits/month, no credit card needed.
How DSPy ReAct works
dspy.ReAct implements the Reasoning and Acting loop. At each iteration, the language model reasons about what it knows, decides which tool to call, receives an observation, and continues until it can answer or hits max_iters. DSPy creates a “finish” tool automatically; the model calls it when it has enough information to construct the final output.
import dspy
def get_price(ticker: str) -> float:
"""Return the current price in USD for a given ticker symbol."""
... # call a real API here
react = dspy.ReAct("question -> answer", tools=[get_price])
result = react(question="What is the current ETH price?")
print(result.answer)
Three things to notice:
- The signature string
"question -> answer"defines the agent’s I/O contract. DSPy enforces that the agent produces ananswerfield — no parsing required. - Tools are plain callables. DSPy reads the type hints and docstring to build the schema the model sees.
dspy.Toolis optional. Pass plain functions directly, or wrap explicitly withdspy.Tool(fn, name="...", desc="...")for fine-grained control over the tool name and description shown to the model.
What you’ll build
A single evmquery_read tool that any DSPy ReAct agent can invoke. The tool accepts a chain identifier, a named contract address map, a CEL expression, and optional context variables. It calls the evmquery REST API and returns the decoded result along with the block number.
If you want to query the chain interactively from Claude Desktop or Cursor without writing code, the evmquery MCP server is the faster path. For Python agents where you want full control over schema, error handling, and DSPy’s prompt optimizer, the function tool below is the right surface.
Setup
pip install dspy requests
export ANTHROPIC_API_KEY=sk-ant-...
export EVMQUERY_API_KEY=evmq_...
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 evmquery tool
evmquery’s REST API takes a chain, a named contract map, a CEL expression, and optional typed context variables. The response includes a decoded result.value and the meta.blockNumber at which it was read.
One format note: the API requires each contract entry to be {"address": "0x..."}, not a bare address string. The tool below accepts plain address strings from the model and converts them internally — the model never needs to know about evmquery’s schema shape.
import os
from typing import Optional
import requests
EVMQUERY_API = "https://api.evmquery.com/api/v1/query"
def evmquery_read(
chain: str,
contracts: dict,
expression: str,
context: Optional[dict] = 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 or event logs.
Args:
chain: Chain identifier — evm_ethereum, evm_base, or evm_bnb_mainnet.
contracts: Map 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. Pass a single address string or a list
of addresses for multi-wallet expressions.
"""
def _type(v) -> str:
return "list<sol_address>" if isinstance(v, list) else "sol_address"
contract_schema = {k: {"address": v} for k, v in contracts.items()}
body: dict = {
"chain": chain,
"schema": {"contracts": contract_schema},
"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']})"
Two implementation notes worth keeping:
- The docstring is the schema. DSPy passes it to the model as the tool description. The scope constraint (“Do NOT use for historical data”) reduces misrouted calls.
- Type inference for context variables. evmquery requires knowing whether a value is a single address (
sol_address) or a list (list<sol_address>). The_typehelper infers this from the Python value, so the model only needs to pass a string or a list.
Three live queries
All expressions below were validated against the live chain before publication.
ERC-20 balance check
evmquery_read(
chain="evm_ethereum",
contracts={"usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"},
expression="formatUnits(usdc.balanceOf(wallet), usdc.decimals())",
context={"wallet": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"},
)
# → "0.11674 (block 25126787)"
formatUnits reads decimals() from the contract itself. USDC uses 6 decimals, DAI uses 18; the expression handles both without hard-coding the value.
Live ETH/USD price via Chainlink
evmquery_read(
chain="evm_ethereum",
contracts={"eth_usd": "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419"},
expression="formatUnits(eth_usd.latestAnswer(), eth_usd.decimals())",
)
# → "2129.01 (block 25126787)"
No context variables needed: a pure contract read with no wallet parameter. The address is the canonical Chainlink ETH/USD aggregator on Ethereum mainnet.
Aave health factor
evmquery_read(
chain="evm_ethereum",
contracts={"aave": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2"},
expression="formatUnits(aave.getUserAccountData(wallet).healthFactor, 18)",
context={"wallet": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"},
)
# → "1.157920892373162e+59 (block 25126766)"
healthFactor represents how far above the liquidation threshold a position sits. Values above 1.0 are safe; below 1.0 triggers liquidation. For wallets with no active borrow position, evmquery returns approximately 1.16e59: this is the protocol’s uint256 maximum divided by 1e18 and represents infinite health, not an error. Make sure your agent’s system instructions document this so the model doesn’t surface a confusing number to the user.
Struct fields use dot notation: aave.getUserAccountData(wallet).healthFactor. Drop the field access to return the full six-field struct (total collateral, total debt, available borrows, LTV, liquidation threshold, health factor) as a JSON object.
Building the DSPy ReAct agent
With the tool defined, the agent is five lines:
import dspy
dspy.configure(lm=dspy.LM("anthropic/claude-sonnet-4-6"))
agent = dspy.ReAct(
"question -> answer",
tools=[evmquery_read],
max_iters=5,
)
result = agent(question="What is the current ETH price in USD according to Chainlink?")
print(result.answer)
# ETH is trading at $2,129.01 as of block 25,126,787 on the Chainlink oracle.
max_iters=5 caps the reasoning loop. For simple contract reads, the agent finishes in two iterations: one tool call, one finish call. Raising the limit is useful for multi-step queries (fetch a wallet’s balance, then check if it exceeds a threshold) but adds latency on each extra iteration.
Switching providers
DSPy uses LiteLLM under the hood — swap the model string to use any supported provider. dspy.LM("openai/gpt-4o-mini") for OpenAI, dspy.LM("gemini/gemini-1.5-pro") for Google. The tool definition and agent code stay identical; DSPy normalizes the tool-calling interface across providers.
For production agents, a typed dspy.Signature subclass gives field-level control over descriptions and output structure:
class BlockchainQuery(dspy.Signature):
"""Answer questions about live EVM contract state using the evmquery tool."""
question: str = dspy.InputField(desc="A question about current blockchain data")
answer: str = dspy.OutputField(
desc="The answer, including the exact numeric value and block number"
)
agent = dspy.ReAct(BlockchainQuery, tools=[evmquery_read], max_iters=5)
The desc on answer tells the model to always include the block number. For scheduled monitoring jobs or data freshness audits this matters: the block number tells the caller exactly when the value was read.
DSPy’s optimizer (dspy.MIPROv2 or BootstrapFewShot) can further improve tool selection quality by compiling few-shot examples into the agent’s prompts. This is the main differentiator from most other frameworks: evmquery queries become part of a learnable program, not a fixed prompt.
Developers building DeFi tooling and automated portfolio monitors can find more expression patterns in the AI users guide, covering the REST tool and MCP surfaces side by side.
Multi-wallet scans
For portfolio monitoring across multiple addresses, pass a list to context and use the CEL map macro:
evmquery_read(
chain="evm_ethereum",
contracts={"usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"},
expression="wallets.map(w, formatUnits(usdc.balanceOf(w), usdc.decimals()))",
context={
"wallets": [
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
"0xBE0eB53F46cd790Cd13851d5EFf43D12404d33E8",
]
},
)
Passing a Python list triggers list<sol_address> automatically via the _type helper. One API round-trip returns all balances in order — no loop, no N separate tool calls from the model.
DSPy tool vs MCP: picking the right surface
| DSPy tool (this post) | MCP server | |
|---|---|---|
| Language | Python | Any MCP client |
| Setup | ~25 lines in your agent file | Paste one config block |
| Control | Full: schema, error handling, logging | Client manages the conversation |
| Prompt optimization | Yes — MIPRO and BootstrapFewShot apply | No |
| Best for | Python agents, optimized pipelines | Claude Desktop, Cursor, VS Code |
If you want to improve tool selection quality over time using DSPy’s optimizer, the function tool is the right surface. If you want to query the chain interactively from your 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 — same function-as-tool pattern with the
@tooldecorator - Monitor Aave health factors with a Python polling script
- Browse the evmquery REST API docs for the full CEL expression reference and multi-wallet macros