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:
- Poll a job queue – a simple KV store that holds JSON‑encoded tasks submitted by external users via an HTTP endpoint.
- Select the highest‑priority task – priority is encoded as a numeric fee the caller is willing to pay in USDC.
- 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.
- 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).
- 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 |
+----------------+ +----------------+
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
}
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;
}
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));
}
}
};
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 };
}
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:
- 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)) })
};
}
- It returns a
402with the invoice in thePayheader. The caller (a frontend or another service) must then present a signed USDC transfer on Base that matches the invoice. - 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. - On success, the agent writes a record to the
PAYMENT_LEDGERKV 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');
}
}
*Trade
Top comments (0)