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 an LLM‑driven agent to work on‑chain, with a focus on practical implementation, trade‑offs, and operational realities.


1. Why an “earning‑while‑sleeping” agent?

The idea isn’t to print money; it’s to let a deterministic software loop perform a narrow, repeatable task that pays a small fee in USDC each time it succeeds. The agent I built does micro‑tasks on a decentralized service catalog (the x402‑paid endpoint list you’ll see at the end). Each successful call nets $0.01‑$0.10, and the agent can run thousands of calls per day if the underlying service is available.

The key constraints I kept in mind:

Constraint Reason Implementation choice
Predictable cost We don’t want the agent to burn more in gas/LLM fees than it earns. Use a cheap, fast LLM (e.g., gpt-3.5-turbo-0125) with function‑calling; batch LLM calls where possible.
Deterministic safety Hallucinations could lead to invalid on‑chain calls. Wrap every LLM output in a strict JSON schema validator; fallback to a rule‑based handler on failure.
Observable We need to know if the agent is stuck or losing money. Export Prometheus metrics (calls, successes, failures, earned USDC).
Low ops overhead Should run unattended for days. Deploy as a lightweight Docker container on a cheap VPS or Cloudflare Workers (the latter for the x402 gateway).

2. High‑level architecture

+-------------------+      +-------------------+      +-------------------+
|  Scheduler (cron) | ---> |  Agent Core Loop  | ---> |  x402 Gateway     |
|  (every 5 min)    |      |  - LLM planner    |      |  (USDC payment)   |
+-------------------+      |  - Tool executor  |      +-------------------+
                           |  - State store    |
                           +-------------------+
                                    |
                                    v
                           +-------------------+
                           |  Monitoring/Alert |
                           +-------------------+
Enter fullscreen mode Exit fullscreen mode
  • Scheduler – a simple cron job (or Cloudflare Workers cron trigger) starts the agent every few minutes. This prevents a runaway loop if the agent crashes.
  • Agent Core Loop – the heart: receives a goal (e.g., “call any available endpoint that pays ≥ $0.02”), asks the LLM to pick a tool, validates the tool call, executes it, records the result, and repeats until a time budget or success threshold is met.
  • x402 Gateway – a thin wrapper that forwards the agent’s HTTP request to the actual service endpoint, attaches the required USDC payment (via the x402 protocol), and returns the response plus any refund/change.
  • State store – a SQLite file (or Redis if you prefer) holds the agent’s nonce, earned balance, and recent failures to avoid replay attacks.
  • Monitoring – Prometheus endpoint exposed on :9090/metrics. Alerts fire if success rate drops below 80% or if earned USDC stalls for > 1 h.

3. Choosing the LLM & tooling

I experimented with three setups:

Setup Latency (avg.) Cost per 1k tokens Reliability (function‑call success)
gpt-4-turbo-preview 1.2 s $0.03 96 %
gpt-3.5-turbo-0125 0.6 s $0.0015 92 %
Local Llama‑2‑13B (GGUF) 2.8 s (CPU) $0 (host) 78 %

The cost vs. reliability trade‑off made gpt-3.5-turbo the sweet spot: cheap enough that the LLM fee (< $0.0002 per call) is negligible compared to the USDC payout, and its function‑calling support is solid. I kept a fallback to a rule‑based selector (pick the highest‑paying endpoint that hasn’t failed in the last 5 min) for the ~8 % of calls where the LLM returned malformed JSON.

Tool definition (JSON Schema) – each tool corresponds to an x402‑paid endpoint. The schema is generated once from the catalog and shipped with the agent.

{
  "name": "call_endpoint",
  "description": "Invoke an x402‑paid service endpoint and return its raw response.",
  "parameters": {
    "type": "object",
    "properties": {
      "endpoint": { "type": "string", "enum": ["get_price", "mint_nft", "data_feed", /*  */] },
      "args": {
        "type": "object",
        "additionalProperties": false,
        "description": "Endpoint‑specific payload (see catalog)."
      }
    },
    "required": ["endpoint", "args"],
    "additionalProperties": false
  }
}
Enter fullscreen mode Exit fullscreen mode

The agent asks the LLM to output a JSON object that conforms to this schema. A quick jsonschema.validate() call either accepts the payload or triggers the fallback.


4. Working code snippets

Below is a minimal, runnable version of the agent core. It assumes you have:

  • An OpenAI API key (OPENAI_API_KEY env var).
  • Access to an x402 gateway that accepts a signed USDC payment header (X-PAYMENT: <base64‑signed-tx>). In practice, the gateway handles the signing; the agent just adds the header.
  • A local SQLite file state.db for nonce tracking.

python
# agent.py
import os, json, time, sqlite3, logging, asyncio
from typing import Dict, Any
import openai
import jsonschema
import aiohttp
from prometheus_client import start_http_server, Counter, Gauge

# -------------------- Config --------------------
OPENAI_MODEL = "gpt-3.5-turbo-0125"
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
assert OPENAI_API_KEY, "Set OPENAI_API_KEY"
openai.api_key = OPENAI_API_KEY

# Metrics
CALLS = Counter("agent_calls_total", "Total agent invocations")
SUCCEEDS = Counter("agent_success_total", "Successful endpoint calls")
FAILS = Counter("agent_fail_total", "Failed endpoint calls")
EARNED = Gauge("agent_earned_usdc", "USDC earned so far")
# ------------------------------------------------

# ---------- State persistence ----------
DB_PATH = "state.db"

def init_db():
    con = sqlite3.connect(DB_PATH)
    cur = con.cursor()
    cur.execute("""CREATE TABLE IF NOT EXISTS state (
        nonce INTEGER PRIMARY KEY,
        earned_usdc REAL DEFAULT 0
    )""")
    con.commit()
    con.close()

def get_state() -> Dict[str, Any]:
    con = sqlite3.connect(DB_PATH)
    cur = con.cursor()
    cur.execute("SELECT nonce, earned_usdc FROM state ORDER BY nonce DESC LIMIT 1")
    row = cur.fetchone()
    con.close()
    if row:
        return {"nonce": row[0], "earned_usdc": row[1]}
    return {"nonce": 0, "earned_usdc": 0.0}

def update_state(nonce: int, earned: float):
    con = sqlite3.connect(DB_PATH)
    cur = con.cursor()
    cur.execute("INSERT OR REPLACE INTO state (nonce, earned_usdc) VALUES (?, ?)", (nonce, earned))
    con.commit()
    con.close()
# ----------------------------------------

# ---------- Tool schema (simplified) ----------
# In a real build you would fetch this from the catalog endpoint.
TOOL_SCHEMA = {
    "name": "call_endpoint",
    "description": "Invoke an x402‑paid service endpoint and return its raw response.",
    "parameters": {
        "type": "object",
        "properties": {
            "endpoint": {"type": "string", "enum": ["get_price", "data_feed", "mint_nft"]},
            "args": {
                "type": "object",
                "additionalProperties": False
            }
        },
        "required": ["endpoint", "args"],
        "additionalProperties": False
    }
}
# ------------------------------------------------

async def ask_llm(goal: str) -> Dict[str, Any]:
    """Ask the LLM to pick a tool and fill its arguments."""
    messages = [
        {"role": "system", "content": "You are an agent that selects a tool to call. Respond ONLY with valid JSON matching the supplied tool schema."},
        {"role": "user", "content": f"Goal: {goal}\nAvailable tool: {json.dumps(TOOL_SCHEMA)}"}
    ]
    resp = await openai.ChatCompletion.acreate(
        model=OPENAI_MODEL,
        messages=messages,
        temperature=0.0,
        max_tokens=200,
    )
    content = resp.choices[0].message["content"].strip()
    try:
        payload = json.loads(content)
        jsonschema.validate(payload, TOOL_SCHEMA)
        return payload
    except (json.JSONDecodeError, jsonschema.ValidationError) as e:
        logging.warning(f"LLM output invalid: {e}")
        return None   # trigger fallback

async def execute_tool
Enter fullscreen mode Exit fullscreen mode

Top comments (0)