From Prompt to Paycheck: Wiring an LLM Chain Into Real Gig Platforms
For developers building autonomous AI agents that actually earn money.
1. Why a “prompt‑to‑paycheck” pipeline matters
Most demos stop at a clever chat completion. To turn that into a billable service you need three layers that work together:
| Layer | Responsibility | Typical failure points |
|---|---|---|
| Prompt/Chain | Turns a user request into a deterministic sequence of LLM calls, tool uses, and post‑processing. | Hallucination, token blow‑up, uncontrolled recursion. |
| Execution Runtime | Hosts the chain, manages state, retries, and exposes a clean HTTP/JSON‑RPC endpoint. | Cold start latency, scaling limits, secret leakage. |
| Payment & Metering | Records each successful call, charges the caller in USDC (or another stablecoin) via a smart‑contract escrow, and optionally refunds on failure. | Gas price volatility, replay attacks, disputable outcomes. |
If any layer is weak, the whole pipeline either loses money (over‑charging or under‑charging) or trust (bad outputs, missed SLAs). The following sections show a minimal, production‑ish implementation that keeps each concern isolated while staying easy to iterate on.
2. The LLM chain: deterministic, observable, and cheap
We’ll use LangChain (v0.2+) because it gives us composable Runnable objects, built‑in token counting, and easy swapping of back‑ends (OpenAI, Anthropic, local Llama.cpp). The example chain does three things:
- Extract intent – a tiny classifier that maps a free‑form gig request to a known skill (e.g., “write SEO blog post”).
- Run a skill‑specific sub‑chain – a prompt‑filled LLM call plus optional tool use (e.g., web search).
- Validate output – a lightweight regex/JSON schema check; if it fails we retry up to N times or fallback to a human‑in‑the‑loop queue.
# file: agent_chain.py
from langchain_core.runnables import RunnableSequence, RunnableLambda
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import JsonOutputParser
import json, re, os
LLM = ChatOpenAI(model="gpt-4o-mini", temperature=0.2, api_key=os.getenv("OPENAI_KEY"))
# 1️⃣ Intent classifier – returns {"skill": "seo_blog", "confidence": 0.93}
INTENT_PROMPT = ChatPromptTemplate.from_messages([
("system", "You are a gig‑router. Classify the user request into one of: "
"[seo_blog, social_copy, code_review, data_summarize]. "
"Return JSON with keys skill and confidence (0‑1)."),
("human", "{request}")
])
intent_chain = INTENT_PROMPT | LLM | JsonOutputParser()
# 2️⃣ Skill‑specific sub‑chains (example: SEO blog)
SEO_PROMPT = ChatPromptTemplate.from_messages([
("system", "Write a 600‑word SEO‑optimized blog post about {topic}. "
"Include H2 headings, bullet points, and a meta description ≤160 chars."),
("human", "{topic}")
])
SEO_CHAIN = SEO_PROMPT | LLM | (lambda x: x.content) # raw text output
# 3️⃣ Validator – checks length and presence of meta description
def validate_seo_blog(text: str) -> dict:
if not (500 <= len(text) <= 800):
raise ValueError("Length out of bounds")
if not re.search(r"(?i)meta description:", text):
raise ValueError("Missing meta description")
return {"output": text, "status": "ok"}
SEO_VALIDATOR = RunnableLambda(validate_seo_blog)
# Assemble the full chain with retry logic
def make_skill_chain(skill: str):
if skill == "seo_blog":
return intent_chain | (lambda d: SEO_CHAIN.invoke({"topic": d["request"]})) | SEO_VALIDATOR
# add other skills similarly …
raise NotImplementedError(skill)
# Entry point used by the runtime
def run_chain(request: str) -> dict:
# First classify
intent = intent_chain.invoke({"request": request})
skill = intent.get("skill")
if not skill:
return {"error": "Unable to classify request", "status": "fail"}
try:
result = make_skill_chain(skill).invoke({"request": request})
return {**intent, **result, "status": "ok"}
except Exception as e:
# simple retry – could be swapped for exponential backoff + dead‑letter queue
return {"error": str(e), "status": "fail", "intent": intent}
Trade‑offs in the chain
| Decision | Why we chose it | Cost / Risk |
|---|---|---|
Mini model (gpt-4o-mini) for classification |
Cheap, low latency (~150 ms), sufficient for a 4‑class problem. | Might mis‑classify ambiguous prompts → fallback to human review. |
| Full‑size model for skill execution (you could swap to a cheaper model per skill) | Quality matters for deliverable content; we keep the option to downgrade later. | Higher per‑token cost (~$0.006/1k tokens). |
| Deterministic validator + retry | Guarantees a minimum SLA (length, required fields) before we charge. | Adds latency (extra LLM call on failure) and complexity; we cap retries to 2 to avoid runaway loops. |
| JSON‑only interfaces | Easy to unit test, monitor, and plug into any HTTP gateway. | Slight overhead vs. raw strings, but negligible compared to LLM latency. |
3. Runtime: exposing the chain as a billable micro‑service
We deploy the chain behind a Cloudflare Workers script (you could also use AWS Lambda, Fly.io, or a self‑hosted Kubernetes pod). The worker does three things:
- Authenticate the caller via a signed JWT (issued by your platform’s billing system).
- Meter the request – increment a usage counter in a durable object or KV store.
- Execute the chain, returning the result or an error payload with the appropriate HTTP status.
// file: worker.js
import { Chain } from "./agent_chain.js"; // assuming we transpile/pyodide‑wrap the Python logic
import { verifyJwt } from "./auth.js";
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // Base USDC
const PRICE_MAP = {
seo_blog: 0.05, // $0.05 per successful call
social_copy: 0.02,
code_review: 0.08,
data_summarize: 0.04,
};
addEventListener("fetch", event => {
event.respondWith(handleRequest(event.request));
});
async function handleRequest(request) {
const url = new URL(request.url);
if (url.pathname !== "/run") return new Response("Not Found", { status: 404 });
// 1️⃣ Auth
const auth = request.headers.get("Authorization") || "";
const match = auth.match(/^Bearer\s+(.+)$/);
if (!match) return unauthorized();
const jwt = match[1];
let payload;
try { payload = await verifyJwt(jwt, /* publicKey */); }
catch (e) { return unauthorized(); }
const userId = payload.sub; // e.g., your platform's internal user id
// 2️⃣ Parse body
let body;
try { body = await request.json(); }
catch { return badRequest("Invalid JSON"); }
const { prompt } = body;
if (typeof prompt !== "string") return badRequest("Missing 'prompt' field");
// 3️⃣ Meter + price lookup (we defer actual on‑chain charge to a webhook)
const intent = await Chain.classify(prompt); // reuse the intent classifier from Python
const skill = intent.skill;
const priceUSDC = PRICE_MAP[skill] ?? 0;
if (priceUSDC === 0) return new Response("Unsupported skill", { status: 400 });
// Increment usage counter (Durable Object or KV)
await incrementUsage(userId, skill, 1); // pseudo‑function
// 4️⃣ Run chain
let result;
try { result = await Chain.run(prompt); }
catch (err) {
// On failure we still count the attempt (you may choose to refund)
await logFailure(userId, skill, err.message);
return new Response(JSON.stringify({ error: err.message, status: "fail" }), {
status: 500,
headers: { "Content-Type": "application/json" }
});
}
// 5️⃣ Successful response – include meta for billing webhook
return new Response(JSON.stringify({
...result,
billing: { skill, priceUSDC, userId, timestamp: Date.now() }
}), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
/* Helper responders */
function unauthorized() { return new Response("Unauthorized", { status: 401 }); }
function badRequest(msg) { return new Response(msg, { status: 400, headers: {"Content-Type":"text/plain"}}); }
/* Stubs – replace with your actual storage */
async function incrementUsage(userId, skill, cnt) { /* KV increment */ }
async function logFailure(userId, skill, msg) { /* log to R2 or external service */ }
Observability & reliability notes
- Durable Objects give us per‑user
Top comments (0)