// A paid text-statistics API for AI agents, on Cloudflare Workers. // GET /stats?text=... $0.002 in USDC on Base, paid with x402 // GET /sample free preview of the same output, so an agent can see what it gets // GET / what this is, in plain JSON // A request that fails (no text, text too long) is never charged: payment settles // only after the answer succeeds. import { Hono } from "hono"; import { paymentMiddleware, x402ResourceServer } from "@x402/hono"; import { ExactEvmScheme } from "@x402/evm/exact/server"; import { HTTPFacilitatorClient } from "@x402/core/server"; import { createFacilitatorConfig } from "@coinbase/x402"; import { bazaarResourceServerExtension, declareDiscoveryExtension } from "@x402/extensions/bazaar"; type Env = { PAY_TO: string; // your wallet address on Base (0x...): where payments arrive CDP_API_KEY_ID: string; // from portal.cdp.coinbase.com, stored as a secret CDP_API_KEY_SECRET: string; // same, stored as a secret }; const PRICE = "$0.002"; const NETWORK = "eip155:8453"; // Base mainnet const MAX_CHARS = 5000; const SAMPLE_TEXT = "The quick brown fox jumps over the lazy dog."; // ---------- the product: text statistics ---------- function countSyllables(word: string): number { const w = word.toLowerCase().replace(/[^a-z]/g, ""); if (!w) return 0; if (w.length <= 3) return 1; const groups = w.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, "").replace(/^y/, "").match(/[aeiouy]{1,2}/g); return Math.max(1, groups ? groups.length : 1); } function textStats(text: string) { const words = text.match(/[A-Za-z0-9'’-]+/g) ?? []; const wc = words.length; const sentences = wc ? Math.max(1, (text.match(/[.!?]+(\s|$)/g) ?? []).length) : 0; const syllables = words.reduce((n, w) => n + countSyllables(w), 0); // Flesch reading ease: higher is easier; 60-70 is plain English. Very short, simple text can exceed 100. const flesch = wc ? 206.835 - 1.015 * (wc / sentences) - 84.6 * (syllables / wc) : 0; const STOP = new Set("the a an and or but of to in on for with is are was were be been it this that as at by from".split(" ")); const freq = new Map(); for (const w of words) { const k = w.toLowerCase(); if (k.length > 2 && !STOP.has(k)) freq.set(k, (freq.get(k) ?? 0) + 1); } return { characters: text.length, words: wc, sentences, reading_time_seconds: Math.round((wc / 230) * 60), flesch_reading_ease: Math.round(flesch * 10) / 10, top_words: [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([word, count]) => ({ word, count })), }; } // ---------- the web app ---------- const app = new Hono<{ Bindings: Env }>(); app.get("/", (c) => c.json({ name: "Text statistics", paid: { route: "/stats?text=...", price: PRICE + " USDC", network: "Base", protocol: "x402" }, free_sample: "/sample", limits: { max_characters: MAX_CHARS }, })); app.get("/sample", (c) => c.json({ sample: true, input: SAMPLE_TEXT, ...textStats(SAMPLE_TEXT) })); // The payment step, built once per Worker instance from your settings. let pay: ReturnType | null = null; app.use("/stats", async (c, next) => { if (!pay) { const facilitator = new HTTPFacilitatorClient(createFacilitatorConfig(c.env.CDP_API_KEY_ID, c.env.CDP_API_KEY_SECRET)); const server = new x402ResourceServer(facilitator) .register(NETWORK, new ExactEvmScheme()) .registerExtension(bazaarResourceServerExtension); pay = paymentMiddleware({ "GET /stats": { accepts: [{ scheme: "exact", price: PRICE, network: NETWORK, payTo: c.env.PAY_TO }], description: "Text statistics: word, sentence and character counts, reading time, Flesch reading ease and top words. Pass the text as ?text= (up to 5,000 characters).", mimeType: "application/json", serviceName: "Text statistics", tags: ["text", "readability", "writing"], // Lets the Coinbase facilitator list this route in its Bazaar catalogue, where agents look for services. extensions: declareDiscoveryExtension({ input: { text: SAMPLE_TEXT }, inputSchema: { properties: { text: { type: "string", maxLength: MAX_CHARS } }, required: ["text"] }, output: { example: textStats(SAMPLE_TEXT) }, }), }, }, server); } return pay(c, next); }); app.get("/stats", (c) => { const text = c.req.query("text") ?? ""; if (!text.trim()) return c.json({ error: "add ?text=... (the text to analyse)" }, 400); if (text.length > MAX_CHARS) return c.json({ error: `text is longer than ${MAX_CHARS} characters` }, 413); return c.json(textStats(text)); }); export default app;