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

Building an autonomous agent that can perform useful work and receive payment without constant supervision is an interesting engineering challenge. In this post I’ll walk through the concrete pieces I assembled, the code that makes it tick, and the compromises I had to accept. The goal is not to sell a “magic money‑making bot” but to show a reproducible pattern for developers who want to experiment with paid, on‑chain agent services.


1. High‑level architecture

The agent runs as a long‑lived worker on Cloudflare Workers (you could swap this for any serverless platform). Its core loop is:

  1. Poll a job queue – a simple KV store that holds JSON‑encoded tasks submitted by external users via an HTTP endpoint.
  2. Select the highest‑priority task – priority is encoded as a numeric fee the caller is willing to pay in USDC.
  3. Execute the task – the agent calls a language model (LLM) with a tool‑calling interface, performs any needed side‑effects (e.g., fetching data, writing a file), and returns a result.
  4. Settle payment – using the x402 protocol, the agent presents an invoice to the caller, receives USDC on Base, and records the transaction in a transparent ledger (another KV namespace).
  5. Loop – after a short sleep (to avoid hot‑spinning) the process repeats.

The diagram below shows the data flow:

+----------------+       +----------------+       +----------------+
|  HTTP API      | --->  | Job Queue (KV) | --->  | Agent Worker   |
| (submit task)  |       +----------------+       +----------------+
+----------------+                ^                       |
                                   |   x402 invoice/pay   |
                                   v                       v
                         +----------------+       +----------------+
                         |  Payment Ledger| <---- |  LLM + Tools   |
                         +----------------+       +----------------+
Enter fullscreen mode Exit fullscreen mode

2. The job queue and task schema

Tasks are tiny JSON objects:

{
  "id": "task-2024-09-24-001",
  "payload": { "prompt": "Summarize the latest Bitcoin news", "max_tokens": 200 },
  "fee_usdc": 0.05,   // amount the caller agrees to pay
  "created_at": 1727184000
}
Enter fullscreen mode Exit fullscreen mode

The worker reads from a KV namespace called TASK_QUEUE. Because KV is eventually consistent, we use a simple compare‑and‑swap loop to claim a task atomically:

export async function claimTask() {
  const list = await TASK_QUEUE.list(); // returns all keys
  // sort by fee descending, then by age
  const sorted = list.keys
    .map(k => ({ key: k.name, value: null }))
    .sort((a, b) => {
      const aFee = JSON.parse(await TASK_QUEUE.get(a.key)).fee_usdc;
      const bFee = JSON.parse(await TASK_QUEUE.get(b.key)).fee_usdc;
      return bFee - aFee || new Date(await TASK_QUEUE.get(a.key)).created_at - new Date(await TASK_QUEUE.get(b.key)).created_at;
    });

  for (const entry of sorted) {
    const raw = await TASK_QUEUE.get(entry.key);
    const task = JSON.parse(raw);
    // try to delete; if another worker beat us, the delete fails and we continue
    const deleted = await TASK_QUEUE.delete(entry.key);
    if (deleted) return task;
  }
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Trade‑off: KV gives low latency and global distribution but lacks strong ordering. The compare‑and‑swap mitigates race conditions, yet under heavy load you may see occasional duplicate claims. For a low‑volume experiment this is acceptable; a production system would swap to a durable queue (e.g., Redis Streams or Amazon SQS).


3. Agent loop

The core scheduled handler runs every 30 seconds (configured via a Cron Trigger). Inside we:

export default {
  async scheduled(_, env) {
    while (true) {
      const task = await claimTask();
      if (!task) break; // queue empty for now

      const result = await executeTask(task);
      await settlePayment(task, result, env);
      // brief pause to avoid tight looping when tasks arrive fast
      await new Promise(r => setTimeout(r, 200));
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

The executeTask function isolates the LLM call and any tool usage:

async function executeTask({ payload }) {
  const { prompt, max_tokens } = payload;
  // Using OpenAI‑compatible API; replace with your provider of choice
  const resp = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${OPENAI_API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      model: 'gpt-4o-mini',
      messages: [{ role: 'user', content: prompt }],
      max_tokens,
      tools: [{ type: 'function', function: { name: 'fetch_url', description: 'GET a URL and return text', parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } } }],
      tool_choice: 'auto'
    })
  });

  const data = await resp.json();
  // If the model wants to call a tool, handle it here
  if (data.choices[0].message.tool_calls) {
    for (const call of data.choices[0].message.tool_calls) {
      if (call.function.name === 'fetch_url') {
        const { url } = JSON.parse(call.function.arguments);
        const txt = await fetch(url).then(r => r.text());
        // feed the result back as a tool response
        // (simplified: we just append to prompt and re‑call)
        // In a real agent you’d iterate until no more tool calls.
        return { summary: txt.slice(0, 500) };
      }
    }
  }
  return { summary: data.choices[0].message.content };
}
Enter fullscreen mode Exit fullscreen mode

Trade‑off: Using a hosted LLM introduces latency and cost per token. I chose gpt-4o-mini because it balances price (~$0.00015/1k tokens) with decent reasoning. If you need stricter latency (<200 ms) you’d have to run a smaller open‑source model locally, which brings its own maintenance overhead.


4. Payment settlement with x402

The x402 spec lets you embed a payment request in an HTTP 402 Payment Required response. My agent follows the flow:

  1. After producing a result, it builds an invoice:
   function buildInvoice(task, result) {
     return {
       scheme: 'x402',
       network: 'base',
       currency: 'usdc',
       amount: task.fee_usdc * 1e6, // x402 uses microunits
       payload: JSON.stringify({ taskId: task.id, resultHash: sha256(JSON.stringify(result)) })
     };
   }
Enter fullscreen mode Exit fullscreen mode
  1. It returns a 402 with the invoice in the Pay header. The caller (a frontend or another service) must then present a signed USDC transfer on Base that matches the invoice.
  2. The agent verifies the transaction by reading the Base RPC (via a public endpoint like https://base.mainnet.rpc.dev) and checking that the transfer amount and payload match.
  3. On success, the agent writes a record to the PAYMENT_LEDGER KV namespace:
   async function settlePayment(task, result, env) {
     const invoice = buildInvoice(task, result);
     // In a real implementation you’d send the 402 response to the caller.
     // Here we simulate verification by checking a pre‑signed tx hash passed via env.
     const txHash = env.TX_HASH; // set by the caller in a preceding step
     const tx = await fetch(`https://base.mainnet.rpc.dev?tx=${txHash}`).then(r => r.json());
     if (tx.value === invoice.amount && tx.payload === invoice.payload) {
       await env.PAYMENT_LEDGER.put(task.id, JSON.stringify({ status: 'paid', txHash, timestamp: Date.now() }));
     } else {
       throw new Error('Payment verification failed');
     }
   }
Enter fullscreen mode Exit fullscreen mode

*Trade

Top comments (0)