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:
- Decision‑making loop – the agent must observe its environment, decide on an action, and execute it without human intervention.
- Value capture – each action that creates value for a counterparty must be tied to a verifiable, low‑friction payment.
- 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)
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
}
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;
}
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 };
}
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' } },
);
}
//
Top comments (0)