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) |
+----------------+
- 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.
- 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.
-
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. - 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.
- 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;
}
3.2. Payment escrow using x402
The x402 spec defines a simple HTTP header‑based payment flow. I wrapped it in a helper that:
- Checks the
Payment-Requiredheader (contains the amount and token address). - Calls the
paymethod on the ERC‑20 USDC contract (via a JSON‑RPC provider). - Returns a
Payment-Receiptheader 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;
}
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;
}
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 ?? "";
}
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(`
Top comments (0)