Building Lunark: AI Agent Architecture for Blockchain Operations

A deep dive into how Lunark uses the Astreus framework to create an AI agent that can execute blockchain operations through natural language. Covers the tool system, agent caching, context injection, and the challenges of building autonomous blockchain agents.

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. ![Lunark chat interface showing natural language balance check](/images/menu/lunark/chat-1.jpg) ## 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. ```typescript:src/agent/LunarkAgent.ts class LunarkAgent { private agent: Agent; private userAddress: string; private chainId: number; async initialize() { this.agent = await Agent.create({ name: `lunark-agent-${this.userAddress.slice(0, 10)}`, model: 'gpt-4o-mini', systemPrompt: this.buildSystemPrompt(), }); this.agent.registerPlugin(lunarkPlugin); return this.agent; } private buildSystemPrompt(): string { return `You are Lunark, an AI assistant for blockchain operations. User's wallet: ${this.userAddress} Current network: ${getNetworkName(this.chainId)} (Chain ID: ${this.chainId}) You have access to tools for checking balances, transferring tokens, swapping on DEXes, and managing contacts. Always confirm details before executing transactions.`; } } ``` 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 involves initializing the LLM connection, loading tools, and setting up memory. For a chat application where users send multiple messages, recreating the agent each time would be wasteful. The solution is a per-user cache with a 30-minute TTL: ```typescript:src/agent/LunarkAgent.ts private static agentCache = new Map<string, { agent: Agent; chainId: number; lastUsed: number; }>(); private static CACHE_TTL = 30 * 60 * 1000; async getOrCreateAgent(): Promise<Agent> { const cached = LunarkAgent.agentCache.get(this.userAddress); if (cached && cached.chainId === this.chainId && Date.now() - cached.lastUsed < LunarkAgent.CACHE_TTL) { cached.lastUsed = Date.now(); return cached.agent; } const agent = await this.initialize(); LunarkAgent.agentCache.set(this.userAddress, { agent, chainId: this.chainId, lastUsed: Date.now(), }); return agent; } ``` 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: ```typescript:src/agent/LunarkAgent.ts setInterval(() => { const now = Date.now(); for (const [address, entry] of LunarkAgent.agentCache) { if (now - entry.lastUsed > LunarkAgent.CACHE_TTL) { LunarkAgent.agentCache.delete(address); } } }, 5 * 60 * 1000); ``` ## The Tool System Tools are how the agent interacts with the blockchain. Each tool is a function with a defined schema that the LLM can invoke. Lunark implements 13 tools organized into 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` ![Contact management interface for saved addresses](/images/menu/lunark/contacts.jpg) Each tool follows a consistent pattern: ```typescript:src/tools/transfer.ts const transferTool: ToolDefinition = { name: 'transfer', description: 'Transfer tokens to another address', parameters: [ { name: 'to', type: 'string', description: 'Recipient address, contact name, or ENS', required: true }, { name: 'amount', type: 'string', description: 'Amount to send', required: true }, { name: 'token', type: 'string', description: 'Token symbol (defaults to native)', required: false }, ], handler: async (params, context) => { // Tool implementation }, }; ``` ## 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: ```typescript:src/agents/lunark-agent.ts // Register tools with user context (address + chainId + agent instance + chatId) for (const tool of lunarkPlugin.tools) { const toolWithContext = { ...tool, handler: async (params: Record<string, unknown>) => { const result = await tool.handler(params, { userAddress: this.userAddress, chainId: this.chainId, agent: this, // Pass LunarkAgent instance for network switching chatId: this.currentChatId, }); return JSON.parse(JSON.stringify(result)); }, }; await this.agent.registerPlugin({ name: `${lunarkPlugin.name}-${tool.name}`, version: lunarkPlugin.version, description: tool.description, tools: [toolWithContext], }); } ``` 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: ```typescript:src/tools/transfer.ts async prepareTransfer(to: string, amount: string, token: string, context: ToolContext) { // Resolve recipient (could be address, contact, or ENS) const recipient = await this.resolveRecipient(to, context); // Build transaction data let tx: TransactionRequest; if (isNativeToken(token, context.chainId)) { tx = { to: recipient, value: parseEther(amount), }; } else { const tokenContract = getTokenContract(token, context.chainId); tx = { to: tokenContract.address, data: tokenContract.interface.encodeFunctionData('transfer', [ recipient, parseUnits(amount, tokenContract.decimals), ]), }; } // Save to database const savedTx = await prisma.transaction.create({ data: { chatId: context.chatId, userId: context.userAddress, type: 'transfer', status: 'pending', chainId: context.chainId, to: tx.to, value: tx.value?.toString(), data: tx.data, details: { recipient, amount, token }, }, }); // Emit to frontend via Socket.IO emitToUser(context.userAddress, 'pendingTransaction', { id: savedTx.id, type: 'transfer', transaction: tx, details: savedTx.details, buttonText: `Send ${amount} ${token}`, }); return `Transaction prepared. Please confirm in your wallet to send ${amount} ${token} to ${recipient}.`; } ``` 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 needs to: 1. Understand "alice" refers to a saved contact 2. Ask for clarification on the amount 3. Confirm the details before preparing the transaction The system prompt instructs 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: ```typescript:src/agent/LunarkAgent.ts async getOrCreateGraph(chatId: string): Promise<Graph> { const existing = await this.agent.getGraph(`Chat-${chatId}`); if (existing) return existing; return this.agent.createGraph({ name: `Chat-${chatId}`, autoLink: true, }); } ``` 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 approach has several benefits: - Conversations persist across sessions - Context window stays manageable (only recent messages) - The graph structure enables future features like branching conversations