How I Built an Autonomous AI Agent That Earns USDC While I Sleep
Target audience: developers who want to ship self‑sustaining, pay‑per‑use AI services.
1. Why “earn while I sleep” isn’t magic
An autonomous agent that earns USDC does not create value out of thin air. It must:
- Expose a useful capability (e.g., text summarisation, sentiment analysis, code completion).
- Collect payment for each invocation via a verifiable, low‑overhead mechanism.
- Run continuously with minimal operational overhead so the developer can step away.
The x402 protocol satisfies #2: it lets an HTTP endpoint require a micropayment in USDC on the Base L2 before returning a response. The client pays, the gateway verifies the transaction, and the request proceeds to your backend.
My implementation focuses on a summarisation service because it’s easy to demo, has predictable latency, and works well with small open‑source models. The same pattern applies to any other callable function.
2. High‑level architecture
+-------------------+ x402 payment +-------------------+
| Client (any HTTP) | ---------------------> | x402 Gateway (CF)|
+-------------------+ +-------------------+
| Verify USDC payment |
+----------+----------+
|
v
+-------------------+ Internal RPC +-------------------+
| Agent Worker (CF) | <-------------------- | Model Inference |
| (summarise) | (optional fallback) | (ONNX / HF API) |
+-------------------+ +-------------------+
- x402 Gateway – a Cloudflare Workers script that enforces the payment header.
- Agent Worker – another Cloudflare Workers script that receives the request after payment, calls the model, and returns the result.
- Model Inference – either a tiny ONNX model bundled with the Worker (for offline, zero‑cost inference) or a call to a hosted inference API (e.g., HuggingFace Inference Endpoints) when higher quality is needed.
The agent is “autonomous” because once deployed it needs no further human interaction: it waits for paid requests, processes them, and accrues USDC in its wallet.
3. Setting up the x402 payment gateway
Cloudflare Workers provide a cheap, globally distributed runtime. The gateway does three things:
- Reads the
X402-Paymentheader (base64‑encoded JSON with payer address, amount, and transaction hash). - Calls the Base RPC to confirm the transaction succeeded and matches the expected amount.
- If valid, forwards the request to the Agent Worker; otherwise returns
402 Payment Required.
// x402-gateway.js
import { Base } from 'viem/chains';
import { getTransactionReceipt } from 'viem/actions';
const USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; // USDC on Base
const REQUIRED_AMOUNT = BigInt(1000000); // 0.001 USDC (6 decimals)
export default {
async fetch(request, env, ctx) {
const paymentHeader = request.headers.get('X402-Payment');
if (!paymentHeader) {
return new Response('Missing X402-Payment header', { status: 402 });
}
let payload;
try {
payload = JSON.parse(atob(paymentHeader));
} catch (_) {
return new Response('Invalid X402-Payment format', { status: 400 });
}
const { from, to, value, hash } = payload;
if (to.toLowerCase() !== env.AGENT_WALLET.toLowerCase()) {
return new Response('Incorrect payee', { status: 402 });
}
if (value !== REQUIRED_AMOUNT.toString()) {
return new Response('Incorrect amount', { status: 402 });
}
// Verify on‑chain
const receipt = await getTransactionReceipt(Base, { hash });
if (receipt.status !== 'success') {
return new Response('Payment not confirmed', { status: 402 });
}
// Payment OK – forward to agent worker
const agentUrl = new URL(request.url);
agentUrl.hostname = env.AGENT_WORKER_HOST; // set via wr.toml
return fetch(agentUrl, {
method: request.method,
headers: request.headers,
body: request.body,
});
},
};
Trade‑offs
| Aspect | Choice | Reason | Drawback |
|---|---|---|---|
| Gateway location | Cloudflare Workers (global edge) | Low latency, no server management, free tier sufficient for low traffic | Limited execution time (50 ms CPU) – fine for verification only |
| Payment verification | Direct RPC call via viem
|
Trustless, no third‑party relayer | Adds ~150‑200 ms latency; could be cached for repeat nonces if you accept replay risk |
| Token | USDC on Base | Low gas (~$0.0001) and stable value | Requires Base RPC endpoint (free tier from Infura/Alchemy works) |
4. The Agent Worker – summarisation logic
After the gateway confirms payment, the request reaches the Agent Worker. It extracts the text to summarise, runs the model, and returns the result. I used a distilbert‑base‑uncased‑distilled‑squad‑style summariser converted to ONNX and bundled with the Worker (≈12 MB). For higher quality you can swap the model for a hosted endpoint; the code stays the same.
// agent-worker.js
import { createInferenceSession } from 'onnxruntime-web';
// Load model once at cold start (cached across invocations)
let session;
async function loadModel() {
if (!session) {
const response = await fetch('model/distilbart-xsum-12-3.onnx');
const arrayBuffer = await response.arrayBuffer();
const { default: Ort } = await import('onnxruntime-web');
session = await Ort.InferenceSession.create(arrayBuffer, {
executionProviders: ['wasm'],
});
}
return session;
}
// Simple tokenizer placeholder – replace with a real one (e.g., HuggingFace tokenizers)
function tokenize(text) {
// For demo, we just split on whitespace and map to IDs via a tiny vocab.
// In production use @xenova/transformers or similar.
const vocab = JSON.parse(await fetch('model/vocab.json').then(r => r.text()));
return text
.toLowerCase()
.split(/\s+/)
.map(tok => vocab[tok] ?? vocab['[UNK]']);
}
function detokenize(ids) {
const invVocab = JSON.parse(await fetch('model/inv_vocab.json').then(r => r.text()));
return ids.map(i => invVocab[i] || '').join(' ');
}
export default {
async fetch(request, env, ctx) {
if (request.method !== 'POST') {
return new Response('Only POST allowed', { status: 405 });
}
const { text } = await request.json();
if (!text || typeof text !== 'string') {
return new Response('Missing "text" field', { status: 400 });
}
// Ensure model is ready
const sess = await loadModel();
// Pre‑process (tokenize + pad/truncate to model's max length)
const maxLength = 512;
const inputIds = tokenize(text).slice(0, maxLength);
const attentionMask = inputIds.map(() => 1);
// Pad
while (inputIds.length < maxLength) {
inputIds.push(0);
attentionMask.push(0);
}
// Run inference
const feeds = {
input_ids: new Int64Array(inputIds),
attention_mask: new Int64Array(attentionMask),
};
const results = await sess.run(feeds);
const logits = results.logits; // shape [1, seq_len, vocab_size]
const predictedIds = Array.from(
new Int32Array(logits.data).map((v, i) => (i % logits.dims[2] === 0 ? v : 0))
).filter((v, i) => i % logits.dims[2] === logits.dims[2] - 1); // argmax per step
const summary = detokenize(predictedIds);
return new Response(JSON.stringify({ summary }), {
headers: { 'Content-Type': 'application/json' },
});
},
};
Trade‑offs
| Aspect | Choice | Reason | Drawback |
|---|---|---|---|
| Model size | DistilBART‑XSUM (~12 MB ONNX) | Fits within Workers bundle, cold start < 300 ms |
Top comments (0)