Mastra agents are fluent in TypeScript and wired to every major LLM provider, but they arrive blind to the chain. Ask one about a current USDC balance, a Chainlink oracle price, or an Aave health factor and the model either refuses or fabricates a number from stale training data. That data lives on-chain, changes every block, and was never in any corpus.
One typed createTool definition fixes this. Wire it to the evmquery REST API and every Mastra agent in your project gains live read access to any EVM view function on Ethereum, Base, or BNB Smart Chain — no ABI files, no RPC node, no web3 library.
TL;DR
npm install @mastra/core zod, define a createTool that POSTs to https://api.evmquery.com/api/v1/query, and pass it to a Mastra Agent. The agent can then answer questions about USDC balances, Chainlink prices, Aave positions, and any on-chain view function. Free tier: 2,000 credits/month, no credit card needed.
How Mastra tools work
Mastra’s createTool is the TypeScript counterpart to a LangChain @tool or a Vercel AI SDK tool(). You give it an id, a natural-language description, a Zod inputSchema, and an execute function. When the model decides it needs external data, it emits a structured tool call; Mastra validates the arguments against your schema, runs execute, and feeds the result back.
The description is the model’s only instruction for when to use the tool. Specific scope constraints — “use for current contract state, not historical data” — keep the model from routing the wrong queries through it.
What you’ll build
A single evmqueryReadTool that any Mastra agent can invoke. The tool accepts:
- A chain identifier (
evm_ethereum,evm_base, orevm_bnb_mainnet) - A named contract map (
{ usdc: "0xA0b8..." }) - A CEL expression (
"formatUnits(usdc.balanceOf(wallet), usdc.decimals())") - Optional context variables for wallet addresses
evmquery auto-resolves ABIs and proxy implementations, executes the expression on the live chain, and returns a decoded result. The tool stays under 50 lines.
If you want to query the chain from Claude Desktop or Cursor without writing any TypeScript, the evmquery MCP server gets you there in under five minutes. This post is for building agents programmatically.
Project setup
Start from any Node 18+ TypeScript project.
npm install @mastra/core zod
Set two environment variables:
ANTHROPIC_API_KEY=sk-ant-...
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
The evmquery REST API takes a POST body with a chain, a named contract map, a CEL expression, and optional typed context variables. The response includes a result field with the decoded value and a blockNumber.
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
const EVMQUERY_API = "https://api.evmquery.com/api/v1/query";
export const evmqueryReadTool = createTool({
id: "evmquery-read",
description:
"Read live data from an EVM smart contract. Use for current token balances, " +
"DeFi positions, oracle prices, or any view function on Ethereum, Base, or " +
"BNB Smart Chain. Do not use for historical data or event logs. " +
"Supported chains: evm_ethereum, evm_base, evm_bnb_mainnet.",
inputSchema: z.object({
chain: z
.enum(["evm_ethereum", "evm_base", "evm_bnb_mainnet"])
.describe("Chain to query"),
contracts: z
.record(z.string())
.describe(
"Named contract addresses. Key is the short name used in the expression, " +
"value is the 0x address. Example: { usdc: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' }"
),
expression: z
.string()
.describe(
"CEL expression to evaluate. Named contracts become variables. " +
"Example: formatUnits(usdc.balanceOf(wallet), usdc.decimals())"
),
context: z
.record(z.union([z.string(), z.array(z.string())]))
.optional()
.describe(
"Runtime values for addresses used in the expression. " +
"Pass a string for a single address or an array for multi-wallet expressions. " +
"Example: { wallet: '0xd8dA...' }"
),
}),
outputSchema: z.object({
result: z.string(),
blockNumber: z.number().optional(),
}),
execute: async ({ chain, contracts, expression, context }) => {
const body: Record<string, unknown> = {
chain,
schema: { contracts },
expression,
};
if (context && Object.keys(context).length > 0) {
const schemaContext: Record<string, string> = {};
for (const [k, v] of Object.entries(context)) {
schemaContext[k] = Array.isArray(v) ? "list<sol_address>" : "sol_address";
}
body.schema = { contracts, context: schemaContext };
body.context = context;
}
const res = await fetch(EVMQUERY_API, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": process.env.EVMQUERY_API_KEY!,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`evmquery ${res.status}: ${text}`);
}
const data = (await res.json()) as { result: unknown; blockNumber?: number };
return { result: String(data.result), blockNumber: data.blockNumber };
},
});
Three design choices worth noting:
- The description doubles as routing logic. The sentence “Do not use for historical data or event logs” is not for humans — it prevents the model from calling this tool when it should be doing something else.
- Array detection for context types. evmquery needs to know whether a context variable is a single address (
sol_address) or a list (list<sol_address>). The loop infers this from the JavaScript value so the model never needs to know evmquery’s type system. outputSchemais typed. Mastra uses this to give the model a predictable result shape and to enabletoModelOutputtransforms if you want to reformat the result before the model sees it.
Three live recipes
All expressions below were validated against the live chain before publication.
ERC-20 balance check
// Validated result: 5567.402493 (block 24965806)
const result = await evmqueryReadTool.execute({
chain: "evm_ethereum",
contracts: { usdc: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" },
expression: "formatUnits(usdc.balanceOf(wallet), usdc.decimals())",
context: { wallet: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" },
});
// → { result: "5567.402493", blockNumber: 24965806 }
formatUnits reads decimals() from the contract itself, so scaling is always correct regardless of whether the token uses 6, 8, or 18 decimal places.
Live ETH/USD price via Chainlink
// Validated result: 2346.98301344 (block 24965806)
const result = await evmqueryReadTool.execute({
chain: "evm_ethereum",
contracts: { eth_usd: "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419" },
expression: "formatUnits(eth_usd.latestAnswer(), eth_usd.decimals())",
});
// → { result: "2346.98301344", blockNumber: 24965806 }
No context variables needed — this is a pure contract read. The address is the canonical Chainlink ETH/USD aggregator on Ethereum mainnet. Swap the address and contract name for any Chainlink feed.
Multi-wallet balance scan
// Validated result: [5567.402493, 3, 10.005273] (block 24965808)
const result = await evmqueryReadTool.execute({
chain: "evm_ethereum",
contracts: { usdc: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" },
expression: "wallets.map(w, formatUnits(usdc.balanceOf(w), usdc.decimals()))",
context: {
wallets: [
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
"0xBE0eB53F46cd790Cd13851d5EFf43D12404d33E8",
"0x40B38765696e3d5d8d9d834D8AaD4bB6e418E489",
],
},
});
// → { result: "[5567.402493, 3, 10.005273]", blockNumber: 24965808 }
Passing an array triggers list<sol_address> automatically. The CEL map macro iterates and returns results in order — one RPC round-trip for all three wallets, not three tool calls.
Wiring into a Mastra agent
With the tool defined, the agent setup is minimal:
import { Agent } from "@mastra/core/agent";
import { evmqueryReadTool } from "./tools/evmquery";
export const evmAgent = new Agent({
id: "evm-agent",
name: "EVM Data Agent",
instructions:
"You are a blockchain data assistant. When the user asks 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.",
model: "anthropic/claude-sonnet-4-6",
tools: { evmqueryReadTool },
});
Invoke with a natural-language prompt:
const response = await evmAgent.generate(
"What is the USDC balance of 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 on Ethereum?"
);
console.log(response.text);
// The wallet holds 5,567.40 USDC as of block 24,965,806.
The model sees the tool description, recognises the query requires live data, emits a structured tool call, receives the result, and writes a natural-language reply. You do not write the routing logic.
Multiple tools
Pass additional tools in the tools object alongside evmqueryReadTool. Mastra routes to the right one from the descriptions. A portfolio calculation tool and an alert sender sit cleanly next to a chain-read tool without interfering.
Aave health factor and struct returns
evmquery expressions can return struct fields, not just scalars. Aave’s getUserAccountData method returns a six-field struct; you can read the whole position or pull a single field with dot notation:
const result = await evmqueryReadTool.execute({
chain: "evm_base",
contracts: { aave: "0xA238Dd80C259a72e81d7e4664a9801593F98d1c5" },
expression: "formatUnits(aave.getUserAccountData(wallet).healthFactor, 18)",
context: { wallet: "0xYourWallet..." },
});
healthFactor is a 1e18 fixed-point number — values above 1.0 are safe, below 1.0 trigger liquidation. For wallets with no active borrow position, evmquery returns the maximum uint256 value scaled by 1e18, approximately 1.16e59, representing infinite health (no debt). A wallet with an active position returns something like 1.43. Add a threshold check to your agent instructions and it can classify positions automatically.
For developers building DeFi tooling, struct field access via dot notation unlocks the full Aave position data — collateral, debt, available borrow capacity, and liquidation threshold — all in a single expression.
Mastra tool vs MCP: picking the right surface
| Mastra createTool (this post) | MCP server | |
|---|---|---|
| Language | TypeScript | Any MCP client |
| Setup | ~50 lines in your project | Paste one config block |
| Control | Full: schema, error handling, logging | Client manages the conversation |
| Multi-tool agents | Yes, composable with other tools | Limited to the MCP surface |
| Deployment | Node.js server or serverless | Remote hosted endpoint |
| Best for | Production agents, custom backends | Claude Desktop, Cursor, VS Code |
If you are building a TypeScript agent with custom business logic, createTool gives full control over the schema, error messages, output formatting, and lifecycle hooks. If you want to query the chain interactively from your IDE or Claude Desktop without writing code, the evmquery MCP server gets you there in under five minutes.
Developers who want to explore what the AI integrations surface looks like across all evmquery clients — REST tool, MCP, and n8n — will find side-by-side examples on the AI users page.
Next steps
- Set up the evmquery MCP server in Claude Desktop and Cursor — no code required
- Add a live EVM tool to the Vercel AI SDK in TypeScript
- Build a LangChain agent with onchain data in Python
- Browse the evmquery REST API docs for list macros, multi-contract expressions, and the full CEL function reference