When I set out to build Lunark, the goal was straightforward: let users interact with blockchain through conversation instead of complex interfaces. What seemed simple on the surface turned into an exploration of AI agent architecture, tool design, and the unique challenges of combining LLMs with blockchain operations.
The Core Challenge
Blockchain operations are deterministic and irreversible. Send tokens to the wrong address, and they're gone. Approve a malicious contract, and your wallet can be drained. This creates a fundamental tension with LLMs, which are probabilistic and can hallucinate.
The solution was to build a system where the AI handles understanding and planning, but never has direct access to execute transactions. Every blockchain operation goes through a prepare-sign-submit flow where the user must explicitly approve each action in their wallet.
Agent Architecture
Lunark's agent is built on the Astreus framework, which provides the core primitives for AI agents: tool registration, conversation memory, and streaming responses. The LunarkAgent class wraps this with blockchain-specific functionality.
The system prompt is crucial. It includes the user's wallet address and current network, giving the agent the context it needs to understand queries like "what's my balance?" without asking for clarification.
Per-User Agent Caching
Creating an agent is expensive. It means initializing the LLM connection, loading tools and setting up memory. In a chat application where users send message after message, rebuilding the agent every time would be pure waste.
So each user gets a cached agent with a 30-minute TTL:
The cache key includes the chain ID because the system prompt contains network information. If a user switches networks, they get a fresh agent with the updated context.
A background cleanup task runs every 5 minutes to evict stale entries:
The Tool System
Tools are how the agent touches the blockchain. Each tool is a function with a defined schema that the LLM can invoke. Lunark implements 13 tools in four categories:
- Balance Tools:
get_balance,get_wallet_balance - Transaction Tools:
transfer,swap_tokens,approve_token - Utility Tools:
resolve_token,is_native_token,switch_network,list_tools - Contact Tools:
add_contact,list_contacts,resolve_contact,delete_contact
Each tool follows a consistent pattern:
Context Injection
A key design decision was how to pass user-specific information to tools. The naive approach would be to include it in every tool's parameters, but that's error-prone and verbose.
Instead, Lunark uses context injection. When registering tools, a wrapper adds context to every invocation:
Now every tool automatically receives the user's address, current chain, and other context without the LLM needing to pass it explicitly.
Transaction Preparation
The most sensitive part of the system is transaction handling. When the agent decides to execute a transfer or swap, it doesn't submit directly to the blockchain. Instead, it prepares the transaction and emits it to the frontend:
The frontend receives this event, displays a confirmation UI, and only when the user clicks "Confirm" does it actually submit the transaction using their wallet.
Handling Ambiguity
Natural language is ambiguous. When a user says "send some ETH to alice", the agent first has to understand that "alice" refers to a saved contact. Then it has to notice that "some" is not an amount and ask for one. Only after that can it confirm the details and prepare the transaction.
The system prompt tells the agent to always confirm before executing:
When a user requests a transaction:
1. First resolve all parameters (addresses, amounts, tokens)
2. If any information is missing, ask for clarification
3. Show a summary and ask for confirmation
4. Only then prepare the transaction
This creates a natural conversation flow:
User: Send some ETH to alice Lunark: How much ETH would you like to send to alice (0x1234...5678)? User: 0.5 Lunark: I'll prepare a transfer of 0.5 ETH to alice. Please confirm in your wallet.
Conversation Memory
Lunark uses Astreus's Graph system for conversation persistence. Each chat session is a Graph, and each message exchange is a Task node within that graph:
The autoLink: true setting automatically connects sequential tasks, creating a conversation chain. When loading context for a new message, the last 10 tasks are retrieved and included in the prompt.
This buys a few things at once. Conversations persist across sessions, because the graph lives outside the agent. The context window stays manageable, because only the recent messages go into the prompt. And since the history is a graph rather than a flat list, it leaves room for features like branching conversations later.
Keep reading
- The Technical Architecture of Modern AI Agents
This is the first in my technical series on AI agent architecture. Having built numerous production-grade AI agent systems, I'm sharing insights from my implementation experience. I'll take you behind the scenes into the technical foundations that make these systems work.
- Real-Time Streaming with Socket.IO: Building Responsive Web3 Chat
How Lunark implements real-time message streaming using Socket.IO, including the challenges of React state batching, room-based broadcasting, and coordinating blockchain transactions with chat responses.
- Multi-Chain DEX Aggregation: How Lunark Finds the Best Swap Rates
A technical walkthrough of Lunark's DEX aggregation system, covering quote fetching across Uniswap, SushiSwap, Curve, PancakeSwap, and TraderJoe, fee tier optimization, and the challenges of building a multi-protocol swap system.