DEV Community

Nikhil Ranka
Nikhil Ranka

Posted on

How I Built an Autonomous AI Agent That Earns USDC While I Sleep

How I Built an Autonomous AI Agent That Earns USDC While I Sleep

Author: Senior Engineer – Autonomous Systems


Introduction

The promise of “AI that makes money while you sleep” often slides into marketing fluff. In practice, an autonomous agent that earns a cryptocurrency like USDC must solve three concrete problems:

  1. Decision‑making loop – the agent must observe its environment, decide on an action, and execute it without human intervention.
  2. Value capture – each action that creates value for a counterparty must be tied to a verifiable, low‑friction payment.
  3. Operational safety – the agent must limit financial exposure, avoid infinite loops, and be observable enough to intervene when something goes wrong.

Below I walk through the architecture I settled on, the concrete code that makes it run on Cloudflare Workers (so it stays cheap and always‑on), and the trade‑offs I had to accept. The goal is not to sell a “get‑rich‑quick” scheme but to show a reproducible pattern for developers who want to experiment with paid, autonomous services.


Core Components

Component Responsibility Tech Choice Why
Perception Pull data from external APIs (price feeds, Twitter, etc.) fetch inside a Worker Workers run close to Cloudflare edge, low latency, free tier sufficient for polling every few minutes.
Reasoning Convert raw data into a decision (e.g., “sell signal”) Small LLM hosted on‑prem (Llama‑3‑8B) via Ollama, or a hosted inference endpoint (e.g., Replicate) Keeping the model small reduces cost and latency; we can swap it for a larger model if needed.
Action Execute the decision (place a limit order, post a tweet, call a micro‑service) Exchange SDK (e.g., coinbase-cloud for Coinbase Advanced Trade) or custom HTTP endpoint The action must be idempotent and signed; we use API keys with restricted permissions.
Payment & Settlement Receive USDC for each successful action x402 protocol + Coinbase USDC on Base (via x402-js library) x402 lets us attach a micropayment header to every HTTP request; the caller pays before we compute the response.
Observability Logs, metrics, alerting Cloudflare Logpush → Sentry + Grafana Cloud Gives us visibility into success/failure rates and cost per call.

The loop looks like this:

[Perception] → [Reasoning] → [Action] → [Emit x402‑paid response] → (repeat)
Enter fullscreen mode Exit fullscreen mode

All components run inside a single Worker script for simplicity; in production you could split them into separate services behind a durable object or a queue.


Perception: Pulling Market Data

I chose a simple moving‑average crossover on ETH/USDC as the trigger. The Worker fetches the last 20 candles from the Coinbase public API every 5 minutes.

// perception.ts
interface Candle {
  time: string;
  low: string;
  high: string;
  open: string;
  close: string;
  volume: string;
}

async function fetchEthUsdcCandles(): Promise<Candle[]> {
  const resp = await fetch(
    'https://api.exchange.coinbase.com/products/ETH-USDC/candles?granularity=300', // 5‑min candles
  );
  if (!resp.ok) throw new Error(`Coinbase error: ${resp.status}`);
  // Coinbase returns [time, low, high, open, close, volume]
  const raw = await resp.json();
  return raw
    .map((c: any) => ({
      time: new Date(c[0] * 1000).toISOString(),
      low: c[1],
      high: c[2],
      open: c[3],
      close: c[4],
      volume: c[5],
    }))
    .slice(-20); // keep most recent 20 candles
}
Enter fullscreen mode Exit fullscreen mode

Trade‑off: Using a public endpoint eliminates the need for an API key, but we are subject to rate limits (≈10 req/s). For a production‑grade agent you’d want a paid data feed with higher reliability and maybe a WebSocket stream.


Reasoning: Tiny LLM for Signal Generation

Instead of hard‑coding a moving‑average rule, I let a 2‑parameter LLM decide whether the crossover is “strong enough” to warrant a trade. The model receives the last two closes and outputs a JSON { action: "buy"|"sell"|"hold", confidence: number }.

// reasoning.ts
import { Ollama } from 'ollama';

const ollama = new Ollama({ host: process.env.OLLAMA_HOST ?? 'http://localhost:11434' });

interface Signal {
  action: 'buy' | 'sell' | 'hold';
  confidence: number; // 0‑1
}

async function generateSignal(candles: Candle[]): Promise<Signal> {
  const recent = candles.slice(-2);
  const prompt = `
You are a trading assistant. Given the last two closing prices for ETH/USDC:
${recent.map(c => `- ${c.time}: ${c.close}`).join('\n')}
Decide whether to BUY, SELL, or HOLD. Output ONLY a JSON object with fields:
{ "action": "buy"|"sell"|"hold", "confidence": 0.0‑1.0 }`;

  const resp = await ollama.generate({
    model: 'llama3:8b',
    prompt,
    format: 'json',
    temperature: 0.2,
  });

  // Ollama returns { response: '{"action":"buy","confidence":0.87}' }
  const parsed = JSON.parse(resp.response) as Signal;
  // Defensive clamping
  parsed.confidence = Math.max(0, Math.min(1, parsed.confidence));
  return parsed;
}
Enter fullscreen mode Exit fullscreen mode

Trade‑off: Running Llama‑3‑8B locally adds ~2 GB RAM and ~150 ms inference latency on a modest CPU. If you move to a hosted endpoint, you trade latency for operational simplicity and pay per‑token fees. For a micro‑transaction‑based service, the extra latency is acceptable because the caller pays for the response anyway.


Action: Placing a Limit Order on Coinbase

When the signal confidence exceeds a threshold (I chose 0.7), the agent creates a limit order 0.2 % above the mid‑price for a sell, or 0.2 % below for a buy. The order size is fixed at $10 USDC to keep risk bounded.

// action.ts
import { AdvancedTradeClient } from 'coinbase-cloud';

const client = new AdvancedTradeClient({
  apiKey: process.env.COINBASE_API_KEY!,
  apiSecret: process.env.COINBASE_API_SECRET!,
  passphrase: process.env.COINBASE_PASSPHRASE!,
});

interface OrderResult {
  id: string;
  status: string;
}

async function placeLimitOrder(
  side: 'buy' | 'sell',
  sizeUsdc: number,
  price: number,
): Promise<OrderResult> {
  const order = {
    client_order_id: `agent-${Date.now()}`,
    product_id: 'ETH-USDC',
    side,
    order_configuration: {
      limit_limit_gtc: {
        base_size: String(sizeUsdc / price), // ETH amount
        limit_price: String(price.toFixed(4)),
        post_only: false,
      },
    },
  };

  const resp = await client.orders.createOrder(order);
  if (!resp.success) throw new Error(`Order failed: ${resp.error_message}`);
  return { id: resp.order_id, status: resp.status };
}
Enter fullscreen mode Exit fullscreen mode

Trade‑off: Using a fixed $10 size keeps exposure predictable, but it also caps earnings. In a real system you’d size the order dynamically based on confidence, recent volatility, and available USDC balance. Additionally, we rely on Coinbase’s sandbox‑like testnet for early validation; moving to mainnet introduces slippage and potential adverse selection—something to monitor with alerts.


Payment: x402 Micropayment Header

The Worker’s entry point receives an HTTP request from a consumer (another agent, a dashboard, or a human). Before we run the perception‑reasoning‑action pipeline, we check for a valid x402 payment header. If the payment is present and sufficient, we proceed; otherwise we return a 402 Payment Required response with the required amount.


ts
// index.ts (Worker entry)
import { x402 } from '@x402/cloudflare-worker';
import { ethUsdcPriceFeed } from './perception';
import { generateSignal } from './reasoning';
import { placeLimitOrder } from './action';

export default {
  async fetch(request: Env, env: ExecutionContext): Promise<Response> {
    // 1️⃣ Verify payment – we charge 0.05 USDC per call
    const payment = x402.verify(request, {
      token: 'USDC',
      network: 'base', // USDC on Base chain
      amount: '0.05', // 5 cents
    });

    if (!payment.valid) {
      return new Response(JSON.stringify({ error: 'Payment required' }), {
        status: 402,
        headers: {
          'Content-Type': 'application/json',
          ...payment.requiredHeaders, // tells caller how to pay
        },
      });
    }

    // 2️⃣ Core loop (simplified)
    try {
      const candles = await ethUsdcPriceFeed();
      const signal = await generateSignal(candles);

      if (signal.confidence < 0.7 || signal.action === 'hold') {
        return new Response(
          JSON.stringify({ signal, note: 'No trade executed' }),
          { headers: { 'Content-Type': 'application/json' } },
        );
      }

      //
Enter fullscreen mode Exit fullscreen mode

Top comments (0)