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

Target audience: developers who want to put a language model to work for micropayments on‑chain.


1. Why an autonomous earning agent?

The idea is simple: expose a useful capability (e.g., text summarisation, data extraction, or a tiny ML inference) as a paid API, let the agent call that API on its own behalf, and settle each call in USDC on the Base layer‑2. The agent does not need a human to trigger it; it runs continuously, checks for pending work, performs the task, collects payment, and repeats.

The motivation is not to replace a full‑time job but to explore the economics of agent‑to‑agent commerce using existing standards (x402) and cheap L2 gas. The trade‑offs are real: you must handle payment failures, guard against abuse, and keep the agent’s behavior predictable enough to avoid costly on‑chain disputes.


2. High‑level architecture

+----------------+      +----------------+      +-------------------+
|  Scheduler /   | ---> |  LLM Core      | ---> |  Tool Executor    |
|  Work Queue    |      | (prompt +      |      | (summariser,      |
+----------------+      |  memory)       |      |  extractor, etc.) |
        ^                +----------------+      +-------------------+
        |                         |                       |
        |                         v                       v
        |               +----------------+      +-------------------+
        |               |  Payment Wrapper| ---> |  x402 Provider    |
        |               | (USDC escrow)   |      | (Base RPC)        |
        |               +----------------+      +-------------------+
        |                         |                       |
        +-------------------------+-----------------------+
                                  |
                         +----------------+
                         |  Persistence   |
                         |  (SQLite/KV)   |
                         +----------------+
Enter fullscreen mode Exit fullscreen mode
  1. Scheduler / Work Queue – a lightweight cron‑like loop that pulls pending jobs from a durable store (e.g., a Cloudflare KV namespace or a Postgres table). Each job contains a payload and a pre‑agreed price in USDC.
  2. LLM Core – the model that reasons about the job. I used a hosted Open‑source model via Together.ai (Llama‑3‑8B) because it offers a predictable per‑token cost and can be called from a Worker without cold‑start penalties.
  3. Tool Executor – deterministic functions that the LLM can call via a simple JSON‑schema interface (similar to OpenAI function calling). Examples: summarize_text, extract_entities, run_sql_query.
  4. Payment Wrapper – before handing control to the LLM, the agent locks the agreed USDC amount in an escrow contract (the x402 standard). After the tool returns a result, the wrapper releases the funds to the agent’s wallet.
  5. Persistence – stores job state, payment receipts, and a nonce to prevent replay attacks. SQLite works fine for a single‑instance deployment; for scaling you’d swap to Postgres or a KV store.

3. Code snippets

Below are the essential parts I ran inside a Cloudflare Worker (the platform gives sub‑second start‑up and free tier usage for low traffic). The same logic can be moved to any Node.js/Deno environment.

3.1. Job definition (TypeScript)

interface Job {
  id: string;            // UUID
  payload: string;       // raw input for the tool
  priceMicroUSDC: number; // e.g., 500_000 = $0.005 (5 milli‑USDC)
  createdAt: number;
}
Enter fullscreen mode Exit fullscreen mode

3.2. Payment escrow using x402

The x402 spec defines a simple HTTP header‑based payment flow. I wrapped it in a helper that:

  1. Checks the Payment-Required header (contains the amount and token address).
  2. Calls the pay method on the ERC‑20 USDC contract (via a JSON‑RPC provider).
  3. Returns a Payment-Receipt header on success, or throws on failure.
import { ethers } from "ethers";

const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // Base USDC
const ERC20_ABI = [
  "function balanceOf(address) view returns (uint256)",
  "function transfer(address to, uint256 amount) returns (bool)",
];

async function payIfRequired(
  response: Response,
  spender: ethers.Wallet,
  expectedMicroUSDC: number
): Promise<Response> {
  const paymentHeader = response.headers.get("Payment-Required");
  if (!paymentHeader) return response; // no paywall

  const { token, amount } = JSON.parse(paymentHeader);
  if (token.toLowerCase() !== USDC_ADDRESS.toLowerCase())
    throw new Error("Unexpected payment token");

  // Convert micro‑USDC to wei (6 decimals)
  const weiAmount = ethers.parseUnits(String(expectedMicroUSDC / 1e6), 6);

  const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, spender);
  const tx = await usdc.transfer(
    response.headers.get("Payee")!, // address that should receive funds
    weiAmount
  );
  await tx.wait();

  // The server should now reply with a Payment-Receipt header.
  // We just return the original response; the caller can verify receipt.
  return response;
}
Enter fullscreen mode Exit fullscreen mode

3.3. LLM call with tool schema

I used the Together.ai /v1/chat/completions endpoint, passing a tools array that describes each executable function.

async function callLLM(messages: any[], tools: any[]) {
  const resp = await fetch(
    "https://api.together.xyz/v1/chat/completions",
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.TOGETHER_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        model: "togethercomputer/llama-3-8b-chat",
        messages,
        tools,
        tool_choice: "auto", // let the model decide
        temperature: 0.2,
      }),
    }
  );
  if (!resp.ok) throw new Error(`LLM error: ${resp.statusText}`);
  const data = await resp.json();
  return data.choices[0].message;
}
Enter fullscreen mode Exit fullscreen mode

3.4. Tool implementation (example: summarisation)

async function summarizeText(text: string): Promise<string> {
  // Very cheap, deterministic summarisation – could be a small ML model or
  // a rule‑based extractor. Here I just call the same LLM with a focused prompt.
  const summaryMsg = await callLLM(
    [
      { role: "system", content: "You are a concise summariser." },
      { role: "user", content: `Summarize the following in ≤2 sentences:\n\n${text}` },
    ],
    [] // no further tools needed
  );
  return summaryMsg.content ?? "";
}
Enter fullscreen mode Exit fullscreen mode

3.5. Worker main loop


ts
export default {
  async scheduled(event, env, ctx) {
    const spender = new ethers.Wallet(
      env.PRIVATE_KEY,
      new ethers.JsonRpcProvider(env.BASE_RPC_URL)
    );

    // 1️⃣ Pull pending jobs (max 10 per tick to stay within limits)
    const jobs: Job[] = await env.JOB_KV.list({ limit: 10 })
      .then(list => Promise.all(list.keys.map(k => env.JOB_KV.get(k.value, "json"))));

    for const job of jobs {
      try {
        // 2️⃣ Resolve price and create a temporary escrow header
        const priceMicro = job.priceMicroUSDC;
        const payHeader = JSON.stringify({
          token: USDC_ADDRESS,
          amount: priceMicro / 1e6, // x402 expects USDC with 6 decimals
        });

        // 3️⃣ Call the tool via LLM – we prepend a payment‑required header
        //    (the tool endpoint itself checks for payment; we simulate it)
        const toolResp = await fetch(
          `https://api.example.com/tools/summarize`,
          {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
              "Payment-Required": payHeader,
            },
            body: JSON.stringify({ text: job.payload }),
          }
        );

        // 4️⃣ Settle payment
        const paidResp = await payIfRequired(toolResp, spender, priceMicro);

        // 5️⃣ Extract result and store receipt
        const result = await paidResp.json();
        await env.RECEIPT_KV.put(
          job.id,
          JSON.stringify({ job, result, ts: Date.now() }),
          { expirationTtl: 60 * 60 * 24 * 7 } // keep a week
        );

        // 6️⃣ Mark job as done
        await env.JOB_KV.delete(job.id);
      } catch (err) {
        console.error(`
Enter fullscreen mode Exit fullscreen mode

Top comments (0)