Integration

Live Onchain Data in Semantic Kernel: Build an EVM Blockchain Plugin

Build a Semantic Kernel plugin with @kernel_function that reads live EVM contract data — token balances, oracle prices, Aave positions — without ABIs or RPC nodes.

evmquery team · · 7 min read
Semantic Kernel plus evmquery: live EVM blockchain data as a kernel plugin

Semantic Kernel ships with connectors to Azure OpenAI, Anthropic, memory stores, and a full plugin system — but nothing for live blockchain data. Ask a ChatCompletionAgent for the current USDC balance of a wallet and it will either refuse or invent a number. That data lives on-chain, changes every block, and has never been in any training corpus.

One @kernel_function method fixes this. Define a plugin class, decorate the method, and pass plugins=[EvmQueryPlugin()] to a ChatCompletionAgent. The kernel’s function-calling loop takes care of the dispatch. You write zero ABI code, zero RPC configuration.

TL;DR

pip install semantic-kernel requests, define a plugin class with a @kernel_function method that POSTs to https://api.evmquery.com/api/v1/query, and pass plugins=[EvmQueryPlugin()] to a ChatCompletionAgent. Your agent can then read live USDC balances, ETH prices from Chainlink, and Aave health factors on Ethereum, Base, or BNB. No ABIs, no RPC node. Free tier: 2,000 credits/month.

How Semantic Kernel plugins work

In Semantic Kernel, a plugin is a Python class whose methods are decorated with @kernel_function. The decorator exports each method’s name and description to the kernel’s function registry, where the LLM can discover and invoke them via native function calling.

Parameters use Annotated[type, "description"] to carry the descriptions the model sees when it builds tool call arguments. The kernel reads the Python type to generate the JSON schema field type and the annotation string to generate the description — both in one place, no separate schema definition required.

When a ChatCompletionAgent receives a user message, it submits all registered function schemas to the model alongside the chat history. The model either replies directly or emits a structured function call. Semantic Kernel dispatches the call, appends the result to the thread history, and continues the loop until the model produces a final natural language reply.

Unlike frameworks where tools are standalone decorated functions, Semantic Kernel plugin classes support constructor injection — useful for sharing an HTTP session, loading credentials at startup, or injecting a service client. The EvmQueryPlugin below keeps things simple, but the pattern scales to production-grade service wiring.

What you will build

A single EvmQueryPlugin class with one query_contract method. The method 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 result with the block number. Any ChatCompletionAgent you wire it into can then answer questions about token balances, oracle prices, and DeFi positions from live on-chain state.

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

Setup

pip install semantic-kernel requests
pip install 'semantic-kernel[anthropic]'  # or semantic-kernel[openai] for GPT-4o
export EVMQUERY_API_KEY=your_key_here
export ANTHROPIC_API_KEY=sk-ant-...

Get a free evmquery key at https://app.evmquery.com/onboarding?plan=free. The free tier includes 2,000 credits per month.

Defining EvmQueryPlugin

import os
from typing import Annotated, Optional

import requests
from semantic_kernel.functions import kernel_function


class EvmQueryPlugin:
    """Read live EVM smart contract state via the evmquery REST API."""

    _endpoint = "https://api.evmquery.com/api/v1/query"

    @kernel_function(
        name="query_contract",
        description=(
            "Read live state from an EVM smart contract. "
            "Use for 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 or event logs."
        ),
    )
    def query_contract(
        self,
        chain: Annotated[str, "Chain identifier: evm_ethereum, evm_base, or evm_bnb_mainnet"],
        contracts: Annotated[
            dict,
            'Map of short name to 0x contract address. Example: {"usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"}',
        ],
        expression: Annotated[
            str,
            'CEL expression referencing contract methods. Example: "formatUnits(usdc.balanceOf(wallet), usdc.decimals())"',
        ],
        context: Annotated[
            Optional[dict],
            'Runtime values for wallet addresses. Example: {"wallet": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"}',
        ] = None,
    ) -> str:
        body: dict = {
            "chain": chain,
            "schema": {
                "contracts": {k: {"address": v} for k, v in contracts.items()}
            },
            "expression": expression,
        }
        if context:
            body["schema"]["context"] = {
                k: "list<sol_address>" if isinstance(v, list) else "sol_address"
                for k, v in context.items()
            }
            body["context"] = context

        resp = requests.post(
            self._endpoint,
            json=body,
            headers={"x-api-key": os.environ["EVMQUERY_API_KEY"]},
            timeout=30,
        )
        resp.raise_for_status()
        data = resp.json()
        return f"{data['result']['value']} (block {data['meta']['blockNumber']})"

Three things worth noting:

  • @kernel_function exports the method. The kernel reads name and description from the decorator when it registers the function. The model reads the description to decide whether and how to call it — a tight, accurate description reduces misrouted calls more than any downstream prompt engineering.
  • Annotated carries parameter descriptions inline. The kernel reads the Python type to produce the JSON schema type and the string annotation to produce the field description. No separate JSON schema file, no descriptor object.
  • 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()} converts the model’s clean {"usdc": "0xA0b..."} map automatically.

Three live recipes

All expressions below were validated against the live chain before publication.

ERC-20 balance check

plugin = EvmQueryPlugin()

result = plugin.query_contract(
    chain="evm_ethereum",
    contracts={"usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"},
    expression="formatUnits(usdc.balanceOf(wallet), usdc.decimals())",
    context={"wallet": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"},
)
# → "0.11674 (block 25141134)"

formatUnits reads decimals() from the contract directly, so the scaling is correct regardless of whether the token uses 6 (USDC), 8, or 18 decimal places.

result = plugin.query_contract(
    chain="evm_ethereum",
    contracts={"eth_usd": "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419"},
    expression="formatUnits(eth_usd.latestAnswer(), eth_usd.decimals())",
)
# → "2142.92 (block 25141134)"

No context needed — this is a pure contract read with no wallet parameter. The address is the canonical Chainlink ETH/USD aggregator on Ethereum mainnet. Swap the address for any other Chainlink feed; the expression stays the same.

Aave health factor

result = plugin.query_contract(
    chain="evm_ethereum",
    contracts={"aave": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2"},
    expression="formatUnits(aave.getUserAccountData(wallet).healthFactor, 18)",
    context={"wallet": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"},
)
# → "1.157920892373162e+59 (block 25141134)"

What the large health factor 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 — totalCollateralBase, totalDebtBase, availableBorrowsBase, and more — all readable from the same expression with no ABI decoding step.

Wiring a ChatCompletionAgent

With the plugin defined, a ChatCompletionAgent that answers on-chain questions takes about fifteen lines:

import asyncio
import os

from semantic_kernel.agents import ChatCompletionAgent
from semantic_kernel.connectors.ai.anthropic import (
    AnthropicChatCompletion,
    AnthropicChatPromptExecutionSettings,
)
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
from semantic_kernel.functions.kernel_arguments import KernelArguments


async def main() -> None:
    settings = AnthropicChatPromptExecutionSettings(
        function_choice_behavior=FunctionChoiceBehavior.Auto(),
    )

    agent = ChatCompletionAgent(
        service=AnthropicChatCompletion(
            ai_model_id="claude-sonnet-4-6",
            api_key=os.environ["ANTHROPIC_API_KEY"],
        ),
        name="BlockchainAssistant",
        instructions=(
            "You are a blockchain data assistant. "
            "Use query_contract to fetch live on-chain data whenever the user asks "
            "about token balances, oracle prices, or DeFi positions. "
            "Always include the block number in your reply."
        ),
        plugins=[EvmQueryPlugin()],
        arguments=KernelArguments(settings=settings),
    )

    response = await agent.get_response(
        messages="What is the current ETH/USD price from Chainlink?"
    )
    print(str(response))


if __name__ == "__main__":
    asyncio.run(main())

FunctionChoiceBehavior.Auto() tells the kernel to let the model decide when to call a function. The model reads the query_contract description, recognises that “ETH/USD price from Chainlink” requires a live contract read, constructs the tool call, and the kernel dispatches to EvmQueryPlugin.query_contract. The result is appended to the thread, the model generates a final reply, and get_response returns it.

Multi-turn conversations

get_response returns an AgentResponseItem that carries a thread attribute. Pass thread=response.thread to the next get_response call to continue the conversation with full history. The agent will remember the wallet address you mentioned in turn one when you ask a follow-up in turn two.

Swapping to OpenAI

The plugin is provider-agnostic. Swapping to GPT-4o is a two-line change:

from semantic_kernel.connectors.ai.open_ai import (
    OpenAIChatCompletion,
    OpenAIChatPromptExecutionSettings,
)

settings = OpenAIChatPromptExecutionSettings(
    function_choice_behavior=FunctionChoiceBehavior.Auto(),
)

agent = ChatCompletionAgent(
    service=OpenAIChatCompletion(
        ai_model_id="gpt-4o",
        api_key=os.environ["OPENAI_API_KEY"],
    ),
    name="BlockchainAssistant",
    instructions="...",
    plugins=[EvmQueryPlugin()],
    arguments=KernelArguments(settings=settings),
)

EvmQueryPlugin is unchanged. Semantic Kernel normalises function calling across providers, so the same plugin works with any model that supports tool use — Azure OpenAI, OpenAI, Anthropic, and any provider with a Semantic Kernel connector.

Developers building production DeFi tooling can find additional expression patterns and chain references 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.