ElizaOS has a growing plugin ecosystem: plugin-evm handles wallet operations like token transfers and swaps, and plugin-goat wraps DeFi protocol calls. What none of them cover cleanly is reading arbitrary contract state from a natural language message — checking a wallet’s USDC balance, querying a Chainlink price feed, or reading an Aave health factor without standing up an RPC node or downloading ABIs.
That gap is what evmquery was built for. One HTTP call, a named contract map, and a CEL expression — and you get back a decoded value from the live chain. This post shows how to wrap that in a single ElizaOS Action and wire it into any character.
TL;DR
Scaffold a plugin with elizaos create --type plugin plugin-evmquery, define an EVM_READ action that POSTs to https://api.evmquery.com/api/v1/query, and register it in your character. Your agent can then answer “what is my USDC balance?” or “is my Aave position safe?” with live on-chain data. No ABIs, no RPC node. Free tier: 2,000 credits/month.
What plugin-evm does (and what it doesn’t)
ElizaOS’s @elizaos/plugin-evm is the standard path for on-chain transactions: send ETH, transfer tokens, approve spenders, bridge across chains. It manages private key signing and transaction submission. That’s the right tool when your agent needs to act on the chain.
Reading contract state is a different operation. View functions don’t touch wallets, cost no gas, and return structured data. They answer questions: what is this wallet’s balance, what does Chainlink say ETH costs, is this DeFi position at risk of liquidation. A DeFi monitoring agent that checks positions before deciding whether to alert still needs reads even if it never writes.
The existing plugins treat reads as a side-channel — you configure specific contracts in advance or handle the ABI plumbing yourself. evmquery removes that: name a contract by address, write an expression in CEL syntax, and the API resolves the ABI automatically and returns a decoded human-readable value.
How ElizaOS actions work
Actions define what an agent can do. Each action has four key parts:
nameandsimiles: identifiers the runtime uses to match actions to messages. Similes expand the match surface without requiring exact phrasing.description: what the LLM sees when deciding whether to invoke the action. Scope constraints belong here.validate: called before the handler. Returnfalseto block the action — for example, when the API key is missing.handler: the implementation. It receives the runtime, the triggering message, optional state, and typed parameters viaoptions.params, then callscallbackto reply and returns anActionResult.
The parameters array lets you declare structured inputs with JSON Schema. When the action fires, the runtime extracts those values from the user message and passes them through options.params.
Setup
Scaffold a project and plugin:
elizaos create --type project my-defi-agent
cd my-defi-agent
elizaos create --type plugin plugin-evmquery
When prompted, choose Quick Plugin (no frontend needed). Add your API key to .env:
EVMQUERY_API_KEY=your_key_here
Get a free key at https://app.evmquery.com/onboarding?plan=free. The free tier covers 2,000 credits per month — roughly 500–1,000 typical contract reads depending on expression complexity.
The evmquery action
Create plugin-evmquery/src/actions/evmRead.ts:
import type {
Action,
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
const EVMQUERY_API = "https://api.evmquery.com/api/v1/query";
export const evmReadAction: Action = {
name: "EVM_READ",
similes: [
"READ_CONTRACT",
"QUERY_BLOCKCHAIN",
"GET_TOKEN_BALANCE",
"CHECK_DEFI_POSITION",
"READ_ONCHAIN",
],
description:
"Read live data from an EVM smart contract on Ethereum, Base, or BNB Smart Chain. " +
"Use for current token balances, DeFi positions, oracle prices, or any contract view function. " +
"Supported chains: evm_ethereum, evm_base, evm_bnb_mainnet. " +
"Do NOT use for historical data, event logs, or transaction submission.",
validate: async (runtime: IAgentRuntime) => {
const key = runtime.getSetting("EVMQUERY_API_KEY");
if (!key) {
logger.error("EVMQUERY_API_KEY is not set");
return false;
}
return true;
},
parameters: [
{
name: "chain",
description: "Chain identifier: evm_ethereum, evm_base, or evm_bnb_mainnet",
required: true,
schema: { type: "string" },
},
{
name: "contracts",
description:
'Named map of contract addresses, e.g. {"usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"}',
required: true,
schema: { type: "object" },
},
{
name: "expression",
description:
'CEL expression to evaluate, e.g. "formatUnits(usdc.balanceOf(wallet), usdc.decimals())"',
required: true,
schema: { type: "string" },
},
{
name: "context",
description: 'Optional runtime values, e.g. {"wallet": "0x..."}',
required: false,
schema: { type: "object" },
},
],
handler: async (
runtime: IAgentRuntime,
_message: Memory,
_state?: State,
options?: Record<string, unknown>,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const params = ((options as { params?: unknown })?.params ??
options ??
{}) as {
chain: string;
contracts: Record<string, string>;
expression: string;
context?: Record<string, unknown>;
};
const { chain, contracts, expression, context } = params;
const body: Record<string, unknown> = {
chain,
schema: {
contracts: Object.fromEntries(
Object.entries(contracts).map(([k, v]) => [k, { address: v }]),
),
},
expression,
};
if (context && Object.keys(context).length > 0) {
(body.schema as Record<string, unknown>).context = Object.fromEntries(
Object.entries(context).map(([k, v]) => [
k,
Array.isArray(v) ? "list<sol_address>" : "sol_address",
]),
);
body.context = context;
}
try {
const apiKey = runtime.getSetting("EVMQUERY_API_KEY") as string;
const resp = await fetch(EVMQUERY_API, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
},
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.text();
return { success: false, text: `evmquery error: ${err}` };
}
const data = (await resp.json()) as {
result: { value: unknown };
meta: { blockNumber: string };
};
const resultText = `${JSON.stringify(data.result.value)} (block ${data.meta.blockNumber})`;
if (callback) {
await callback({ text: resultText, actions: ["EVM_READ"] });
}
return {
success: true,
text: resultText,
values: {
value: data.result.value,
blockNumber: data.meta.blockNumber,
},
};
} catch (error) {
return {
success: false,
text: `Chain read failed: ${(error as Error).message}`,
};
}
},
examples: [
[
{
name: "{{user}}",
content: {
text: "What is the USDC balance of 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045?",
},
},
{
name: "{{agent}}",
content: {
text: "The wallet holds 0.117 USDC as of block 25,105,267.",
actions: ["EVM_READ"],
},
},
],
[
{
name: "{{user}}",
content: { text: "What is the current ETH price in USD?" },
},
{
name: "{{agent}}",
content: {
text: "ETH is trading at $2,227.02 as of block 25,105,267.",
actions: ["EVM_READ"],
},
},
],
],
};
Three things worth noting:
- The
descriptionis the routing signal. ElizaOS passes it to the LLM when deciding which action fires. The explicit “Do NOT use for transactions” prevents misrouting toplugin-evm’s send actions. - Contract wrapping. evmquery expects
{ "address": "0x..." }objects, not bare address strings. TheObject.fromEntriestransform handles this so neither the model nor the caller has to know the API shape. - Context type inference. A list of addresses needs type
"list<sol_address>"in the schema; a single address needs"sol_address". TheArray.isArraycheck handles this automatically.
Three live recipes
All expressions were validated against the live chain before publication.
ERC-20 balance check
// USDC balance on Ethereum mainnet
// Validated result: 0.11674 (block 25105267)
const params = {
chain: "evm_ethereum",
contracts: { usdc: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" },
expression: "formatUnits(usdc.balanceOf(wallet), usdc.decimals())",
context: { wallet: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" },
};
formatUnits reads decimals() from the contract — no hardcoded 6 or 18. The same expression works for any ERC-20.
Live ETH/USD price via Chainlink
// Canonical Chainlink ETH/USD aggregator on mainnet
// Validated result: 2227.02382887 (block 25105267)
const params = {
chain: "evm_ethereum",
contracts: { eth_usd: "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419" },
expression: "formatUnits(eth_usd.latestAnswer(), eth_usd.decimals())",
};
No context needed — a pure view call with no wallet parameter.
Aave health factor
// Aave V3 on Ethereum — getUserAccountData returns a struct
// For wallets with no active borrow: returns ~1.16e59 (infinite health = no debt)
const params = {
chain: "evm_ethereum",
contracts: { aave: "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2" },
expression: "formatUnits(aave.getUserAccountData(wallet).healthFactor, 18)",
context: { wallet: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" },
};
Infinite health factor
Wallets with no active borrow position return 2^256 / 1e18 (~1.16e59) from Aave’s getUserAccountData. This is expected — it means no debt, not an overflow. Add a note in the system prompt: “A health factor above 1e18 means no active borrow position.”
Wiring the plugin into a character
Create plugin-evmquery/src/index.ts:
import type { Plugin } from "@elizaos/core";
import { evmReadAction } from "./actions/evmRead";
export const evmqueryPlugin: Plugin = {
name: "evmquery",
description: "Read live EVM smart contract data via evmquery",
actions: [evmReadAction],
providers: [],
services: [],
};
export default evmqueryPlugin;
export { evmReadAction };
Build and register in your character:
cd plugin-evmquery && bun run build && cd ..
In src/character.ts:
export const character: Character = {
name: "DefiMonitor",
plugins: [
"@elizaos/plugin-sql",
"@elizaos/plugin-openai",
"@elizaos/plugin-bootstrap",
"./plugin-evmquery",
],
system:
"You are a DeFi monitoring assistant. When users ask about token balances, " +
"oracle prices, or DeFi positions, call EVM_READ to fetch live on-chain data. " +
"Always include the block number in your reply so users know the data is current. " +
"A health factor above 1.0 is safe; below 1.0 triggers liquidation. " +
"A health factor above 1e18 means the wallet has no active borrow position.",
};
Start the agent and open http://localhost:3000:
elizaos start
Try prompts like “What is the current ETH price?”, “Check USDC balance of 0xd8dA…”, or “Is my Aave position healthy? wallet is 0x…”. The agent routes each question to EVM_READ, receives the decoded result, and incorporates it into a natural language reply.
ElizaOS plugin vs MCP vs REST: picking the right surface
| ElizaOS plugin (this post) | MCP server | REST API direct | |
|---|---|---|---|
| Language | TypeScript | Any MCP client | Any HTTP client |
| Setup | Scaffold + build | Paste one config block | curl or fetch |
| Agent routing | Automatic via action similes | Client-managed | Manual |
| Best for | ElizaOS agents | Claude Desktop, Cursor | Custom backends |
If you want to query the chain from Claude Desktop or Cursor without writing any code, the evmquery MCP server guide is the faster path. If you’re building a production ElizaOS agent with custom routing, the plugin gives full control over schema, error messages, and how results are formatted before the model sees them.
Developers building blockchain tooling for AI agents will find more expression patterns — multi-wallet scans, reserve data reads, protocol-level aggregations — in the developer resources. The AI users page covers the REST tool and MCP surfaces side by side if you’re comparing integration options.
Next steps
- Set up the evmquery MCP server in Claude Desktop and Cursor — no code required
- Add the same contract-read pattern to LangChain in Python
- Monitor Aave health factors with a scheduled Python script
- Browse the evmquery REST API docs for multi-wallet macros, list filtering, and the full CEL expression reference