Integration

Live Onchain Data in Agno: Build a Custom EVM Blockchain Tool in 30 Lines

Define a custom Agno tool that reads live EVM smart contract data — USDC balances, ETH prices, Aave positions — without ABIs, RPC nodes, or web3.py boilerplate.

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

Agno’s party trick is how little code a custom tool requires: define a Python function, give it a docstring, pass it to Agent(tools=[fn]). That’s the entire integration surface. The framework reads your type hints and docstring to generate the tool schema; the model handles routing. No decorator ceremony, no JSON schema boilerplate.

The gap is blockchain data. Ask an Agno agent about your current USDC balance and it either refuses or hallucinates. That number lives on-chain, changes every block, and was never in any training corpus. A live tool fixes this in under 30 lines.

TL;DR

pip install agno anthropic requests, define an evmquery_read function with a docstring, pass it to Agent(tools=[evmquery_read]), and your Agno 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 Agno tool use works

Any Python function becomes an Agno tool the moment you put it in the tools list. Agno introspects the function signature and docstring to generate the schema the model sees. Type hints map to parameter types; the docstring becomes the description. No import from a tools library, no registration step, no schema file.

from agno.agent import Agent

def get_weather(city: str) -> str:
    """Get the current weather for a city."""
    return f"Sunny, 22°C in {city}"  # replace with real API call

agent = Agent(tools=[get_weather])
agent.print_response("What's the weather in Berlin?")

For blockchain queries this is the right primitive. The model already knows when it needs external data (balance checks, price reads, DeFi positions) and when it doesn’t (explaining how Uniswap V3 works). A well-scoped tool description is all the routing logic you need.

What you’ll build

A single evmquery_read tool that any Agno 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 and the block number it was read from.

If you are building a DeFi dashboard, portfolio monitor, or any agent that needs current contract state, this is the integration surface. If you want to query the chain interactively from Claude Desktop or Cursor without writing code, the evmquery MCP server is the faster path.

Setup

pip install agno anthropic requests
export ANTHROPIC_API_KEY=sk-ant-...
export EVMQUERY_API_KEY=eq_...

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_read 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 and the 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 doesn’t need to know about evmquery’s schema.

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 for wallet addresses used in the expression.
                 Pass a list of addresses for multi-wallet expressions.
                 Example: {"wallet": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"}
    """

    def _type(v) -> str:
        return "list<sol_address>" if isinstance(v, list) else "sol_address"

    # API requires {"address": "0x..."} objects, not bare strings
    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']})"

Three implementation notes worth keeping:

  • The docstring is the schema. Agno passes it directly to the model as the tool description. Scope constraints (“Do NOT use for historical data”) reduce misrouted calls.
  • 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 _type helper infers this from the Python value so the model doesn’t need to know evmquery’s type system.
  • raise_for_status() propagates errors cleanly. The agent receives the error and can retry with a reformulated expression or surface a clear message.

Three live recipes

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 25119626)"

formatUnits reads decimals() from the contract itself — USDC uses 6 decimals, DAI uses 18, but the expression handles both without hard-coding the value.

evmquery_read(
    chain="evm_ethereum",
    contracts={"eth_usd": "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419"},
    expression="formatUnits(eth_usd.latestAnswer(), eth_usd.decimals())",
)
# → "2119.83836857 (block 25119627)"

No context variables needed — this is a pure contract read with no wallet parameter. The address is the canonical Chainlink ETH/USD aggregator on Ethereum mainnet.

WETH balance on Base

evmquery_read(
    chain="evm_base",
    contracts={"weth": "0x4200000000000000000000000000000000000006"},
    expression="formatUnits(weth.balanceOf(wallet), weth.decimals())",
    context={"wallet": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"},
)
# → "0.06282675995491359 (block 46144163)"

Switching chains is a single field change. The same expression pattern works across Ethereum, Base, and BNB without modification.

Wiring into an Agno agent

With the tool defined, the agent takes five lines:

from agno.agent import Agent
from agno.models.anthropic import Claude

agent = Agent(
    model=Claude(id="claude-sonnet-4-6"),
    instructions=(
        "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 so the user knows the result is current."
    ),
    tools=[evmquery_read],
)

Invoke it with a natural language prompt:

response = agent.run(
    "What is the current ETH price in USD according to Chainlink?"
)
print(response.content)
# ETH is trading at $2,119.84 as of block 25,119,627 on the Chainlink oracle.

The model sees the tool description, recognizes the query requires live data, emits a structured tool call, receives the result, and wraps it in a natural language reply. The routing logic lives in the description — you do not wire it up in application code.

Using OpenAI or other providers

Agno supports 30+ model providers. Swap the model with from agno.models.openai import OpenAIChat and model=OpenAIChat(id="gpt-4o") — the tool definition and the rest of the agent code stay identical. Model-agnostic is one of Agno’s design goals.

Struct results and Aave health factors

evmquery expressions return structured values, not just scalars. Aave’s getUserAccountData method returns a six-field struct. You can read the full position in one call or pull a single field:

evmquery_read(
    chain="evm_ethereum",
    contracts={"aave": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2"},
    expression="formatUnits(aave.getUserAccountData(wallet).healthFactor, 18)",
    context={"wallet": "0x..."},
)

healthFactor represents how far above the liquidation threshold the position sits. A value above 1.0 is safe; below 1.0 triggers liquidation. For wallets with no active borrow position, evmquery returns the maximum uint256 divided by 1e18 — a very large number (approximately 1.16e59) that represents no debt, not an error. Include a note in your agent’s instructions so the model interprets this correctly.

To read the full struct — total collateral, total debt, available borrows, LTV, and liquidation threshold — drop the .healthFactor field access:

expression="aave.getUserAccountData(wallet)"

The response includes all six fields as a JSON object. The model can surface the health factor, flag liquidation risk, or compute the collateral ratio without additional parsing in your code.

Developers building DeFi tooling can find more expression patterns — multi-wallet balance filters, reserve data reads, and protocol-level aggregations — in the developer resources. For AI agent workflows specifically, the AI users page covers 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",
            "0x40B38765696e3d5d8d9d834D8AaD4bB6e418E489",
        ]
    },
)

Passing a Python list triggers list<sol_address> automatically via the _type helper. The CEL map macro iterates the list and returns results in order — one API round-trip for all wallets, not three separate tool calls.

Agno tool vs MCP: picking the right surface

Agno tool (this post)MCP server
LanguagePythonAny MCP client
Setup30 lines in your agent filePaste one config block
ControlFull: schema, error handling, loggingClient manages the conversation
Multi-tool agentsYes, composable with other Agno toolsLimited to the MCP surface
Best forProduction agents, custom backendsClaude Desktop, Cursor, VS Code

If you are building a Python agent with custom business logic, the function tool gives full control over schema, error messages, and how results are formatted before the model sees them. 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

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.