Skip to main content
Karen’s agent runtime enables AI assistants to autonomously manage Solana wallets, execute trades, and interact with DeFi protocols using a continuous decision-making cycle.

The Agent Loop: Observe → Think → Act → Remember

Every agent operates in a continuous cycle:
┌─────────────────────────────────────────┐
│  1. OBSERVE                             │
│  Gather current wallet state, balances, │
│  recent transactions                     │
└────────────┬────────────────────────────┘

┌─────────────────────────────────────────┐
│  2. THINK                               │
│  Send observations + strategy + skills  │
│  to LLM → LLM decides next action       │
└────────────┬────────────────────────────┘

┌─────────────────────────────────────────┐
│  3. ACT                                 │
│  Execute the chosen skill via           │
│  SkillRegistry                          │
└────────────┬────────────────────────────┘

┌─────────────────────────────────────────┐
│  4. REMEMBER                            │
│  Persist decision + outcome to memory   │
│  for future context                      │
└────────────┬────────────────────────────┘

          (repeat)
Implementation: src/agent/runtime.ts:184-223

AgentRuntime Class

Source: src/agent/runtime.ts:32-350
export class AgentRuntime {
  private config: AgentConfig
  private walletManager: WalletManager
  private transactionEngine: TransactionEngine
  private logger: AuditLogger
  private llm?: LLMProvider
  private skills: SkillRegistry
  private memory: MemoryStore
  private cycle: number = 0
  private running: boolean = false

  start(): void    // Begin the agent loop
  stop(): void     // Stop the agent
  pause(): void    // Pause the agent
  async chat(message: string): Promise<string>  // Direct chat interface
}

1. Observe Phase

The agent gathers context about its current state. Implementation (src/agent/runtime.ts:227-266):
private async observe(): Promise<Record<string, unknown>> {
  try {
    const balances = await this.walletManager.getBalances(this.config.walletId)
    const wallet = this.walletManager.getWallet(this.config.walletId)
    const recentTxs = this.transactionEngine.getTransactionHistory(
      this.config.walletId,
      5,
    )

    return {
      wallet: {
        name: wallet?.name,
        address: wallet?.publicKey,
      },
      balances: {
        sol: balances.sol,
        tokens: balances.tokens.map((t) => ({
          mint: t.mint,
          balance: t.uiBalance,
        })),
      },
      recentTransactions: recentTxs.map((tx) => ({
        type: tx.type,
        status: tx.status,
        details: tx.details,
        timestamp: tx.timestamp,
      })),
      cycle: this.cycle,
      timestamp: new Date().toISOString(),
    }
  } catch (error: any) {
    return {
      error: `Failed to observe: ${error.message}`,
      cycle: this.cycle,
      timestamp: new Date().toISOString(),
    }
  }
}
Example Observation:
{
  "wallet": {
    "name": "DCA-Bot-wallet",
    "address": "HN7cABqYkE2qYkE2qYkE2qYkE2qYkE2qYkE2qYkE2"
  },
  "balances": {
    "sol": 1.5,
    "tokens": [
      { "mint": "EPjF...V97", "balance": 0.5 }
    ]
  },
  "recentTransactions": [
    { "type": "swap", "status": "confirmed", "details": {...}, "timestamp": "..." }
  ],
  "cycle": 42,
  "timestamp": "2026-03-03T12:00:00.000Z"
}

2. Think Phase

The agent sends observations to the LLM along with its strategy and available skills. Implementation (src/agent/runtime.ts:270-302):
private async think(observations: Record<string, unknown>): Promise<{
  reasoning: string
  action: SkillInvocation | null
  rawResponse: string
}> {
  const systemPrompt = this.buildSystemPrompt()
  const recentMemory = this.memory.formatForContext(this.config.id, 10)

  const userMessage = `CURRENT STATE:\n${JSON.stringify(observations, null, 2)}\n\nRECENT MEMORY:\n${recentMemory}\n\nBased on your strategy and the current state, what would you like to do?`

  const messages: LLMMessage[] = [
    { role: 'system', content: systemPrompt },
    { role: 'user', content: userMessage },
  ]

  const tools = this.skills.getToolDefinitions()
  const response = await this.getLlm().chat(
    messages,
    tools,
    this.config.llmModel,
  )

  const action =
    response.toolCalls && response.toolCalls.length > 0
      ? response.toolCalls[0]
      : null

  return {
    reasoning: response.content || 'No explicit reasoning provided.',
    action,
    rawResponse: response.content,
  }
}
System Prompt Template (src/agent/runtime.ts:324-349):
private buildSystemPrompt(): string {
  return `You are "${this.config.name}", an autonomous AI agent managing a Solana wallet on devnet.

YOUR STRATEGY:
${this.config.strategy}

YOUR CONSTRAINTS:
- Maximum ${this.config.guardrails.maxSolPerTransaction} SOL per transaction
- Maximum ${this.config.guardrails.maxTransactionsPerMinute} transactions per minute
- Daily spending limit: ${this.config.guardrails.dailySpendingLimitSol} SOL
- You are on Solana DEVNET — these are not real funds

YOUR BEHAVIOR:
1. Analyze your current wallet state and recent activity
2. Make a decision based on your strategy
3. Use EXACTLY ONE skill per cycle, or "wait" if no action is needed
4. Always provide clear reasoning for your decisions
5. Be conservative — it's better to wait than make a bad trade
6. Check your balance before making swaps or transfers
7. If your SOL balance is low, consider requesting an airdrop`
}

3. Act Phase

The agent executes the chosen skill through the SkillRegistry. Implementation (src/agent/runtime.ts:306-320):
private async act(action: SkillInvocation): Promise<string> {
  const context: SkillContext = {
    walletId: this.config.walletId,
    agentId: this.config.id,
    walletManager: this.walletManager,
    transactionEngine: this.transactionEngine,
    jupiter: this.jupiter,
    splToken: this.splToken,
    tokenLauncher: this.tokenLauncher,
    staking: this.staking,
    wrappedSol: this.wrappedSol,
  }

  return this.skills.execute(action.skill, action.params, context)
}
Example: If the LLM returns {skill: "swap", params: {inputToken: "SOL", outputToken: "USDC", amount: 0.5}}, the SkillRegistry executes the swapSkill with those parameters.

4. Remember Phase

The agent persists the decision and outcome to its memory store. Implementation (src/agent/runtime.ts:202-222):
const decision: AgentDecision = {
  agentId: this.config.id,
  cycle: this.cycle,
  observations,
  reasoning,
  action,
  outcome,
  timestamp: new Date().toISOString(),
}

this.logger.logDecision(decision)
this.logger.logEvent({ type: 'agent:decision', data: decision })
this.memory.addMemory(this.config.id, {
  cycle: this.cycle,
  reasoning,
  action: action ? `${action.skill}(${JSON.stringify(action.params)})` : null,
  outcome,
  timestamp: new Date().toISOString(),
})
Memory entries are stored in data/memory/{agentId}.json and injected into the next cycle’s “Think” phase.

LLM Providers

Karen supports multiple LLM providers with a unified interface. Supported Providers:
  • OpenAI - GPT-4o, GPT-4o-mini, GPT-3.5-turbo
  • Anthropic - Claude Sonnet 4, Claude 3.5 Sonnet, Claude 3 Haiku
  • xAI - Grok-3-latest
  • Google - Gemini 2.0 Flash
LLM Provider Interface (src/agent/llm/provider.ts:28-35):
export interface LLMProvider {
  name: string
  chat(
    messages: LLMMessage[],
    tools: LLMToolDefinition[],
    model?: string,
  ): Promise<LLMResponse>
}
Implementation: src/agent/llm/openai.ts
export class OpenAIProvider implements LLMProvider {
  name = 'openai'
  private client: OpenAI

  constructor(apiKey?: string) {
    this.client = new OpenAI({
      apiKey: apiKey || process.env.OPENAI_API_KEY,
    })
  }

  async chat(
    messages: LLMMessage[],
    tools: LLMToolDefinition[],
    model: string = 'gpt-4o',
  ): Promise<LLMResponse> {
    const openaiTools = tools.map((t) => ({
      type: 'function' as const,
      function: {
        name: t.name,
        description: t.description,
        parameters: t.parameters,
      },
    }))

    const response = await this.client.chat.completions.create({
      model,
      messages,
      tools: openaiTools,
      tool_choice: 'auto',
    })

    const toolCalls = response.choices[0].message.tool_calls?.map((tc) => ({
      skill: tc.function.name,
      params: JSON.parse(tc.function.arguments),
    })) || []

    return {
      content: response.choices[0].message.content || '',
      toolCalls,
      tokensUsed: {
        input: response.usage?.prompt_tokens || 0,
        output: response.usage?.completion_tokens || 0,
      },
    }
  }
}
Configuration:
OPENAI_API_KEY=sk-...
DEFAULT_LLM_PROVIDER=openai
DEFAULT_LLM_MODEL=gpt-4o

Agent Skills

Skills are the actions agents can perform. Karen includes 17 built-in skills. Skill Interface (src/agent/skills/index.ts:27-35):
export interface Skill {
  name: string
  description: string
  parameters: Record<string, any>  // JSON Schema for parameters
  execute(
    params: Record<string, unknown>,
    context: SkillContext,
  ): Promise<string>
}
  • check_balance - Check wallet SOL and token balances
  • swap - Swap tokens via Jupiter DEX
  • transfer - Send SOL to another address
  • airdrop - Request devnet SOL airdrop
  • token_info - Look up token metadata
  • wait - Do nothing this cycle
Example: src/agent/skills/index.ts:94-121 (check_balance)
export const balanceSkill: Skill = {
  name: 'check_balance',
  description: 'Check your current wallet balances including SOL and all SPL tokens',
  parameters: {
    type: 'object',
    properties: {},
    required: [],
  },
  async execute(_params, context) {
    const balances = await context.walletManager.getBalances(context.walletId)
    const wallet = context.walletManager.getWallet(context.walletId)

    let result = `Wallet: ${wallet?.name} (${wallet?.publicKey})\n`
    result += `SOL: ${balances.sol.toFixed(4)} SOL\n`

    if (balances.tokens.length > 0) {
      result += `\nTokens:\n`
      for (const t of balances.tokens) {
        result += `  ${t.mint}: ${t.uiBalance} (${t.decimals} decimals)\n`
      }
    } else {
      result += `No SPL token holdings.`
    }

    return result
  },
}
  • launch_token - Create a new SPL token with initial supply
  • mint_supply - Mint additional tokens (requires mint authority)
  • revoke_mint_authority - Permanently disable minting
  • burn_tokens - Burn (destroy) tokens
  • close_token_account - Close empty token accounts to reclaim rent
Example: src/agent/skills/index.ts:311-362 (launch_token)
export const launchTokenSkill: Skill = {
  name: 'launch_token',
  description: 'Create a new SPL token on Solana with an initial supply minted to your wallet.',
  parameters: {
    type: 'object',
    properties: {
      name: { type: 'string', description: 'Token name (e.g., "My Agent Token")' },
      symbol: { type: 'string', description: 'Token ticker symbol (e.g., "MAT")' },
      decimals: { type: 'number', description: 'Number of decimal places (default: 9)' },
      initialSupply: { type: 'number', description: 'Initial token supply (default: 1,000,000)' },
    },
    required: ['name', 'symbol'],
  },
  async execute(params, context) {
    const result = await context.tokenLauncher.createToken(
      context.walletManager,
      context.walletId,
      String(params.name),
      String(params.symbol),
      Number(params.decimals || 9),
      Number(params.initialSupply || 1_000_000),
    )

    return `Token launched successfully!\nName: ${result.name} (${result.symbol})\nMint: ${result.mint}...`
  },
}
  • stake_sol - Stake SOL to a validator
  • unstake_sol - Deactivate a stake account
  • withdraw_stake - Withdraw deactivated stake
  • list_stakes - List all stake accounts
  • wrap_sol - Convert SOL to wSOL
  • unwrap_sol - Convert wSOL back to SOL
Example: src/agent/skills/index.ts:438-476 (stake_sol)
export const stakeSkill: Skill = {
  name: 'stake_sol',
  description: 'Stake SOL by delegating to a Solana validator.',
  parameters: {
    type: 'object',
    properties: {
      amount: { type: 'number', description: 'Amount of SOL to stake' },
      validator: { type: 'string', description: 'Validator vote account address (optional)' },
    },
    required: ['amount'],
  },
  async execute(params, context) {
    const result = await context.staking.stakeSOL(
      context.walletManager,
      context.walletId,
      Number(params.amount),
      params.validator ? String(params.validator) : undefined,
    )

    return `Staked ${result.amount} SOL!\nStake Account: ${result.stakeAccount}\nValidator: ${result.validator}...`
  },
}
Full Skill List: See SKILLS.md in the source repository or src/agent/skills/index.ts:695-726

Agent Memory

Agents persist their decision history to learn from past actions. MemoryStore Class (src/agent/memory/memory-store.ts:20-83):
export class MemoryStore {
  private memoryDir: string

  addMemory(agentId: string, entry: MemoryEntry): void {
    const memories = this.getMemories(agentId)
    memories.push(entry)
    const trimmed = memories.slice(-100)  // Keep last 100 memories
    fs.writeFileSync(`data/memory/${agentId}.json`, JSON.stringify(trimmed, null, 2))
  }

  getMemories(agentId: string, limit?: number): MemoryEntry[] {
    const filepath = `data/memory/${agentId}.json`
    if (!fs.existsSync(filepath)) return []
    const data = JSON.parse(fs.readFileSync(filepath, 'utf-8'))
    return limit ? data.slice(-limit) : data
  }

  formatForContext(agentId: string, limit: number = 10): string {
    const memories = this.getMemories(agentId, limit)
    if (memories.length === 0) return 'No previous actions recorded.'

    return memories.map((m) => {
      const action = m.action || 'wait'
      return `[Cycle ${m.cycle}] Action: ${action} | Reasoning: ${m.reasoning} | Outcome: ${m.outcome}`
    }).join('\n')
  }
}
Memory Entry Format:
interface MemoryEntry {
  cycle: number
  reasoning: string
  action: string | null  // "swap({inputToken: 'SOL', outputToken: 'USDC', amount: 0.5})"
  outcome: string
  timestamp: string
}
Example Memory File (data/memory/{agentId}.json):
[
  {
    "cycle": 1,
    "reasoning": "SOL balance is low, requesting airdrop to fund operations",
    "action": "airdrop({amount: 2})",
    "outcome": "Successfully airdropped 2 SOL to your wallet!\nTransaction: 5KJh3...",
    "timestamp": "2026-03-03T12:00:00.000Z"
  },
  {
    "cycle": 2,
    "reasoning": "Balance is now 2 SOL. Executing DCA strategy: swap 0.5 SOL for USDC",
    "action": "swap({inputToken: 'SOL', outputToken: 'USDC', amount: 0.5})",
    "outcome": "Swap executed successfully!\nSold: 0.5 SOL\nReceived: ~4.2 USDC...",
    "timestamp": "2026-03-03T12:00:30.000Z"
  }
]
Memory is injected into the LLM prompt so agents can reference past decisions.

Agent Configuration

AgentConfig Type (src/core/types.ts:136-147):
export interface AgentConfig {
  id: string
  name: string
  walletId: string
  llmProvider: 'openai' | 'anthropic' | 'grok' | 'gemini'
  llmModel: string
  strategy: string  // Free-form strategy description
  guardrails: GuardrailConfig
  loopIntervalMs: number  // Time between cycles (default: 30000ms)
  status: AgentStatus  // 'idle' | 'running' | 'paused' | 'stopped' | 'error'
  createdAt: string
}
Creating an Agent (src/agent/orchestrator.ts:92-163):
const orchestrator = new Orchestrator(walletManager, txEngine, guardrails, logger)

const agentConfig = await orchestrator.createAgent({
  name: 'DCA-Bot',
  strategy: 'Buy 0.5 SOL worth of USDC every cycle when balance > 1 SOL',
  llmProvider: 'openai',
  llmModel: 'gpt-4o',
  loopIntervalMs: 30000,  // 30 seconds
  maxSolPerTransaction: 2.0,
  dailySpendingLimitSol: 10.0,
})

orchestrator.startAgent(agentConfig.id)

Orchestrator: Managing Multiple Agents

The Orchestrator class manages concurrent agents. Key Methods (src/agent/orchestrator.ts:32-266):
export class Orchestrator {
  async createAgent(options: CreateAgentOptions): Promise<AgentConfig>
  startAgent(agentId: string): void
  stopAgent(agentId: string): void
  pauseAgent(agentId: string): void
  async chatWithAgent(agentId: string, message: string): Promise<string>
  listAgents(): AgentConfig[]
  getAgent(agentId: string): AgentConfig | null
  findAgentByName(name: string): AgentConfig | null
  stopAll(): void
  getStats(): { totalAgents, runningAgents, stoppedAgents, idleAgents }
}
Agents are persisted to: data/agents.json

Chat Interface

Agents can respond to direct messages (used by dashboard and CLI). Implementation (src/agent/runtime.ts:140-158):
async chat(message: string): Promise<string> {
  const systemPrompt = this.buildSystemPrompt()
  const recentMemory = this.memory.formatForContext(this.config.id, 10)

  const messages: LLMMessage[] = [
    { role: 'system', content: systemPrompt },
    {
      role: 'user',
      content: `RECENT ACTIVITY:\n${recentMemory}\n\nUSER MESSAGE: ${message}`,
    },
  ]

  const response = await this.getLlm().chat(
    messages,
    this.skills.getToolDefinitions(),
    this.config.llmModel,
  )
  return response.content
}
Usage:
const runtime = orchestrator.getRuntime(agentId)
const response = await runtime.chat('What did you do in the last cycle?')
console.log(response)
// "In the last cycle, I swapped 0.5 SOL for USDC because my balance exceeded 1 SOL..."

Next Steps

Architecture

Understand the full system architecture

Wallets

Learn about wallet creation and management

Security

Explore guardrails and transaction security

Skills Reference

See all 17 available agent skills