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

Build docs developers (and LLMs) love