Integration

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

Define a custom LlamaIndex FunctionTool that queries live EVM smart contract data — token balances, ETH prices, DeFi positions — without ABIs, RPC nodes, or web3.py.

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

LlamaIndex ships with query engines, RAG pipelines, and vector store integrations. What it doesn’t include is any first-class path to reading live smart contract state. If your agent needs the current USDC balance of a treasury wallet, the ETH price from Chainlink, or a DeFi position health factor, you’re left wiring up web3.py, managing ABI files, and juggling RPC provider accounts — none of which belongs in an agent’s tool layer.

There’s a shorter path. One Python function wrapped as a FunctionTool gives every LlamaIndex agent you build live EVM reads on Ethereum, Base, or BNB with zero ABI wrangling.

TL;DR

pip install llama-index-core llama-index-llms-anthropic requests, wrap one Python function with FunctionTool.from_defaults(), and your FunctionAgent can read live USDC balances, ETH prices from Chainlink, and Aave positions on Ethereum, Base, or BNB. No ABIs, no RPC node. Free tier: 2,000 credits/month.

How LlamaIndex tools work

In LlamaIndex, tools are the primitives that give agents access to external capabilities. The simplest form is a plain Python function — FunctionAgent reads the function’s docstring and type annotations to generate the schema it sends to the model. For more control over the name and description, FunctionTool.from_defaults() wraps the function explicitly.

When an agent receives a user message, it submits the tool schemas alongside the chat history to the underlying LLM. The model either responds directly or emits a structured tool call. LlamaIndex dispatches the call, appends the result to history, and loops until the model produces a final response.

The docstring is the most load-bearing part of a tool definition. It is the prose the model reads when deciding whether and how to call the tool. A precise docstring reduces misrouted calls more reliably than any downstream prompt engineering.

LlamaIndex offers several agent types: FunctionAgent uses the LLM provider’s native function-calling API, while ReActAgent uses a reasoning-action loop that works with models that don’t support structured tool calls. Both accept the same tool list.

What you will build

A single query_evm function wrapped as a FunctionTool. The tool accepts a chain identifier, a named contract map, and a CEL expression. evmquery resolves the ABI automatically, executes the expression on the live chain, and returns a decoded human-readable value. Any FunctionAgent or ReActAgent can then answer questions like “what is the current ETH price?” or “what is the USDC balance of this wallet?” from live on-chain data.

For a broader overview of how evmquery fits into AI agent stacks, the AI users page covers MCP and REST surfaces together.

Setup

pip install llama-index-core llama-index-llms-anthropic requests
export EVMQUERY_API_KEY=your_key_here
export ANTHROPIC_API_KEY=sk-ant-...

LlamaIndex is LLM-agnostic. Swap llama-index-llms-anthropic for llama-index-llms-openai and replace the Anthropic import below with OpenAI if you prefer GPT-4o.

Defining the tool

Here is the complete query_evm function and its FunctionTool wrapper:

import os
import json
import requests
from llama_index.core.tools import FunctionTool

ENDPOINT = "https://api.evmquery.com/api/v1/query"
API_KEY  = os.environ["EVMQUERY_API_KEY"]

def query_evm(
    chain: str,
    contracts: dict,
    expression: str,
    context: dict | None = None,
    schema_context: dict | None = None,
) -> str:
    """Query live EVM smart contract state using a CEL expression.

    Supported chains: 'evm_ethereum', 'evm_base', 'evm_bnb_mainnet'.
    contracts: map of local names to contract addresses,
               e.g. {"usdc": "0xA0b8...eB48"}.
    expression: a CEL expression referencing contract methods and
                any context variables, e.g.
                "formatUnits(usdc.balanceOf(wallet), usdc.decimals())".
    context: optional runtime values for parameterized inputs,
             e.g. {"wallet": "0xd8dA..."}.
    schema_context: optional type declarations, e.g.
                    {"wallet": "sol_address"}.
    Returns the evaluated result as a string.
    """
    payload: dict = {
        "chain": chain,
        "schema": {
            "contracts": {k: {"address": v} for k, v in contracts.items()}
        },
        "expression": expression,
    }
    if schema_context:
        payload["schema"]["context"] = schema_context
    if context:
        payload["context"] = context

    resp = requests.post(
        ENDPOINT,
        json=payload,
        headers={"x-api-key": API_KEY},
        timeout=30,
    )
    resp.raise_for_status()
    data = resp.json()
    return json.dumps(data["result"]["value"])


evm_tool = FunctionTool.from_defaults(
    fn=query_evm,
    name="query_evm",
    description=(
        "Read live state from any EVM smart contract on Ethereum, Base, or BNB Chain. "
        "Accepts a chain ID, a map of contract names to addresses, and a CEL expression. "
        "Returns the decoded result. Use for token balances, oracle prices, DeFi positions, "
        "and any other contract view-function data."
    ),
)

Why the address wrapping?

The evmquery REST API requires each contract entry to be {"address": "0x..."}, not a bare address string. The dict comprehension {k: {"address": v} ...} handles this conversion so callers can pass a simpler {"usdc": "0xA0b8..."} map.

Your first agent query

Wire the tool into a FunctionAgent and run it:

import asyncio
from llama_index.core.agent.workflow import FunctionAgent
from llama_index.llms.anthropic import Anthropic

llm = Anthropic(model="claude-sonnet-4-6", api_key=os.environ["ANTHROPIC_API_KEY"])

agent = FunctionAgent(
    tools=[evm_tool],
    llm=llm,
    system_prompt=(
        "You are a blockchain data assistant. Use the query_evm tool to "
        "fetch live on-chain data. Always present numbers with appropriate "
        "units and formatting."
    ),
)

async def main():
    response = await agent.run(
        "What is the current ETH/USD price from Chainlink?"
    )
    print(str(response))

asyncio.run(main())

When the agent receives that question, it calls query_evm with:

  • chain="evm_ethereum"
  • contracts={"eth_usd": "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419"}
  • expression="formatUnits(eth_usd.latestAnswer(), eth_usd.decimals())"

The tool returns 2316.97, and the agent presents it with context. No ABI, no RPC provider, no web3 library.

Reading token balances

Ask the agent for a wallet’s USDC balance:

response = await agent.run(
    "What is the USDC balance of 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 on Ethereum?"
)
# The USDC balance of 0xd8dA...96045 on Ethereum is 120,133.88 USDC.

The model constructs the right expression from the docstring context. You don’t pre-hardcode addresses — the agent derives them from its knowledge of common contracts and from any you provide in the conversation.

To ensure the agent always uses verified contract addresses, you can seed the system prompt with a reference table or pass a JSON lookup as part of the user message.

Scanning multiple wallets

For a list of wallets, declare the context variable as list<sol_address> and use the CEL map macro. Here is how you would wire that as a direct tool call to validate:

result = query_evm(
    chain="evm_ethereum",
    contracts={"usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"},
    expression="wallets.map(w, formatUnits(usdc.balanceOf(w), usdc.decimals()))",
    schema_context={"wallets": "list<sol_address>"},
    context={"wallets": [
        "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
        "0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503",
        "0xBE0eB53F46cd790Cd13851d5EFf43D12404d33E8",
    ]},
)
# "[120133.877066, 0.009929, 3.0]"

Three on-chain reads in one HTTP round trip. Internally evmquery batches them the same way Multicall3 does. When you ask the agent to scan a watchlist, it will invoke the tool once with the full list rather than looping per wallet — as long as your docstring makes list<sol_address> semantics clear.

Type mismatch is a runtime error

Passing a list of addresses but declaring "sol_address" (singular) in schema_context causes a type error at evaluation time. The fix is always "list<sol_address>". Your tool’s docstring already documents this, so a capable model will get it right — but worth knowing if you invoke the tool directly.

A complete DeFi portfolio agent

This example builds a small portfolio-monitoring agent that can answer questions about token balances and live ETH prices across Ethereum and Base:

import asyncio
import os
import json
import requests
from llama_index.core.tools import FunctionTool
from llama_index.core.agent.workflow import FunctionAgent
from llama_index.llms.anthropic import Anthropic

ENDPOINT = "https://api.evmquery.com/api/v1/query"
API_KEY  = os.environ["EVMQUERY_API_KEY"]

# Known contracts for the agent's domain
CONTRACTS = {
    "evm_ethereum": {
        "usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
        "eth_usd": "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419",
    },
    "evm_base": {
        "usdc": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
    },
}

def query_evm(
    chain: str,
    contracts: dict,
    expression: str,
    context: dict | None = None,
    schema_context: dict | None = None,
) -> str:
    """Query live EVM smart contract state using a CEL expression.

    Supported chains: 'evm_ethereum', 'evm_base', 'evm_bnb_mainnet'.
    contracts: map of local names to contract addresses,
               e.g. {"usdc": "0xA0b8...eB48"}.
    expression: CEL expression, e.g.
                "formatUnits(usdc.balanceOf(wallet), usdc.decimals())".
    context: optional runtime values, e.g. {"wallet": "0x..."}.
    schema_context: optional type declarations, e.g.
                    {"wallet": "sol_address",
                     "wallets": "list<sol_address>"}.
    Returns the evaluated result as a JSON string.
    """
    payload: dict = {
        "chain": chain,
        "schema": {
            "contracts": {k: {"address": v} for k, v in contracts.items()}
        },
        "expression": expression,
    }
    if schema_context:
        payload["schema"]["context"] = schema_context
    if context:
        payload["context"] = context

    resp = requests.post(
        ENDPOINT,
        json=payload,
        headers={"x-api-key": API_KEY},
        timeout=30,
    )
    resp.raise_for_status()
    data = resp.json()
    return json.dumps(data["result"]["value"])


evm_tool = FunctionTool.from_defaults(fn=query_evm)

llm = Anthropic(
    model="claude-sonnet-4-6",
    api_key=os.environ["ANTHROPIC_API_KEY"],
)

agent = FunctionAgent(
    tools=[evm_tool],
    llm=llm,
    system_prompt=f"""You are a DeFi portfolio assistant.
Use the query_evm tool for all on-chain lookups.
Known contract addresses: {json.dumps(CONTRACTS, indent=2)}
Always present balances with token symbols and two decimal places.""",
)


async def main() -> None:
    questions = [
        "What is the current ETH price?",
        "What is the USDC balance of 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 on Ethereum?",
        "Compare the USDC balance of that wallet on Ethereum vs Base.",
    ]
    for q in questions:
        print(f"\nQ: {q}")
        resp = await agent.run(q)
        print(f"A: {resp}")


asyncio.run(main())

The agent maintains conversation context across questions. After answering the Ethereum balance, it remembers the wallet address when asked about the Base comparison — and fires two parallel-ish tool calls rather than asking you to repeat the address.

Using ReActAgent for models without native function calling

FunctionAgent requires a model with native tool/function calling support. For models that don’t expose that API, ReActAgent uses a reasoning-action loop instead:

from llama_index.core.agent.workflow import ReActAgent

react_agent = ReActAgent(
    tools=[evm_tool],
    llm=llm,
)

response = await react_agent.run("What is the ETH/USD price right now?")

The tool definition is identical. Switch agent type without touching the tool. This is useful when evaluating different models or running locally against open-weight LLMs via Ollama.

Credits and rate limits

The free tier includes 2,000 credits per month. A single balanceOf call costs 3 credits (2 contract calls + 1 round). A map across any number of wallets costs 2 credits — 1 call, 1 round — because evmquery batches the reads internally. Multi-contract expressions scale with the number of distinct calls in the expression, not the number of wallets.

For applications that poll frequently, the credit model rewards batching. Passing 50 wallets in one map expression costs the same 2 credits as passing one.

If you need the same pattern in other agent frameworks, the LangChain integration uses an @tool decorator and the PydanticAI integration uses @agent.tool_plain — all wrapping the same REST API with the same CEL expression model.

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.