Integration

Live Onchain Data in smolagents: Build a Custom EVM Blockchain Tool

Build a custom smolagents Tool subclass that reads live EVM contract data — token balances, oracle prices, Aave positions — without ABIs, RPC nodes, or web3.py.

evmquery team · · 7 min read
smolagents plus evmquery: live EVM blockchain data as a custom agent tool

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 tool
  • description — the prose the model reads when deciding whether to invoke it
  • inputs — a dict of parameter names to {"type": ..., "description": ...} objects
  • output_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 _type helper 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.

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:

CodeAgentToolCallingAgent
How it calls toolsGenerates and runs Python codeEmits JSON tool-call messages
Best forMulti-step reasoning, batch reads, chained logicSingle-step lookups, structured pipelines
EVM use casePortfolio summaries, multi-wallet scans, conditional alertsDirect wallet queries, webhook-triggered reads
Model requirementAny model that can write PythonAny 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

Two minutes to your first live chain read

2,000 credits a month, no credit card required. Grab a free API key and run your first expression against the live chain today.