AI Product Index
A machine-readable directory ("SEO for AIs") where AI products register themselves so AI agents can discover them.
Documentation
AI Product Index
A machine-readable directory ("SEO for AIs") where AI products register themselves so AI agents can discover them. The customers are AI agents acting autonomously: an agent finds the site, reads llms.txt, and registers a product with zero human steps.
๐ข Live, on real money, and proven. https://index.percall.dev is deployed with the payment rail on mainnet โ
POST /api/auditquotes $0.05 in USDC on Base and settles it to the receiving address. The rail completed its first end-to-end settlement on 2026-07-29: paid by the stockx402-fetchclient, settled on chain, replay refused, recorded on the revenue dashboard. Details in DEPLOY.md โ Phase 3.3. Migrated the same day fromindex.kc-it.pl, which stays attached and answers a method-preserving 308 โ percall.dev is the umbrella domain for the paid-services portfolio this is becoming.
Documentation: NEXT.md โ what's outstanding, and whose turn it is ยท DEPLOY.md โ how to get it live, phase by phase ยท ARCHITECTURE.md โ how it works and why ยท TODO.md โ the changelog, and the reasoning behind each change
- Registration is free and stays free: registering is the "purchase" โ an agent opens a GitHub issue with listing JSON, a workflow validates and publishes it.
- The paid product is
POST /api/audit: an agent-readability audit of any URL, priced per call over x402 (HTTP 402). Its value does not depend on how many listings the registry holds, which is why it โ not the tier upgrade โ is what sits behind the payment gate. - Paid tiers (
verified,featured) exist in the schema and ranking; the[upgrade]flow verifies x402 receipts on chain.
What a listed product gets: a crawlable HTML page with schema.org JSON-LD (/l/<slug>.html), presence in the JSON registry (/api/index.json + /listings/<slug>.json), sitemap inclusion, and llms.txt/llms-full.txt presence.
Strategy: two tracks, one build
- Track A โ human-paid GEO service (revenue now). Humans already pay $29โ489/mo for AI visibility. The offer: done-for-you llms.txt + schema.org JSON-LD + agent-readability audit on the customer's own domain. Sold via the landing page's "For humans" section โ
[hire]issue or email. First jobs delivered by hand; no payment infra until someone pays. - Track B โ the agent registry (asset that ages). This repo. It markets Track A: every listing page is a live demo of the deliverable, and the funnel is built in (agent registers free โ operator sees the listing โ upsell). The seed listings are the registry itself, the Track A service, and the operator's seven other deployed sites (dogfood).
How it works
Static build in the repo root, served by a Cloudflare Worker with static assets (wrangler.toml โ worker/index.js). Zero runtime dependencies; plain-Node scripts. Node โฅ 18.
The Worker exists for the three things GitHub Pages structurally could not do:
| GitHub Pages | Worker | |
|---|---|---|
| HTTP 402 + payment manifest | impossible | POST /api/audit |
Custom response headers, Accept negotiation | impossible | Link: alternates, JSON/markdown variants |
| Request logs | none at all | Analytics Engine โ /api/stats.json |
That last row is the point: before the migration there was no way to tell whether a single agent had ever hit the site.
Worker routes (worker/):
GET /api/search?q=โฆโ ranked search over the registry, withcategory,tagandlimit. Matching is weighted (name > tags > slug > description) and tiered (whole word > substring > shared stem), because on a corpus this small a scorer that can rank an unrelated listing above an exact name match is worse than one that returns nothing: an agent can widen its own query, but it cannot tell a confident wrong answer from a right one. A zero-result answer reports what the corpus does contain and where to register, so a dead end still teaches something.discovery.js.POST /askโ NLWeb (nlweb.ai). Natural language in, schema.org objects out, grounded only in the registry โ nothing is generated. Strips stop-words, then applies a relevance floor at half the top score, because "polish property auctions" matching seven of eight listings on the word "polish" is a worse answer than the two right ones. Accepts the spec's{"query": {"text": โฆ}}, a bare{"query": "โฆ"}, andGET ?query=โฆ, because every hand-written client gets the nesting wrong once.discovery.js.POST /mcpโ Model Context Protocol over streamable HTTP. No auth, nothing to install: any MCP client that accepts a URL can add this index as a tool source (claude mcp add --transport http ai-product-index https://index.percall.dev/mcp). Six tools:search_products,get_product,score_url,search_x402_endpoints,search_mcp_servers,how_to_register.score_urlis proxied through the real/api/scorehandler rather than reimplemented, so an MCP caller and a browser cannot disagree about a grade. Only the JSON-RPC half of the transport is implemented โ a server with no server-initiated messages has nothing to stream, andinitializesays so by declaring onlytools.discovery.js.GET /badge.svg?slug=โฆโ the README badge a listed product embeds;&show=scoreshows its live AโF grade instead of its tier. Reads the committed registry andscores.json, never audits, and always returns a 200 image: it renders inside someone else's README, where a 4xx is a broken icon. Grades are refreshed weekly byscripts/score-listings.mjsin the health cron.badge.js.GET /.well-known/http-message-signatures-directoryโ Ed25519 public keys for the RFC 9421 signatures on every response.signing.js.GET /api/score?url=โฆโ free. The AโF letter grade, the numeric score, and all 13 checks by label with pass/fail. This is what the input box on the homepage calls; the audit runs server-side because a browser cannot read another origin's llms.txt or robots.txt. Cached per URL for an hour (cache hits unmetered), 20 uncached audits/hour/IP.score.js.POST /api/auditโ paid. The same 13 checks plus, for each failure, why it failed, a fix ranked by weight, and a paste-ready code snippet with the caller's own origin substituted in. Validates the target withurlError()before charging, then gates on x402.audit.js.GET /api/stats.jsonโ 30-day request counters by inferred client type and path bucket, plusagent_share. Reads the Analytics Engine dataset over the SQL API; reportsstats_not_enabledwithout credentials.stats.js.GET /api/x402/infoโ the active rail's payment terms and the protocol versions accepted, so an agent can read the price without provoking a 402.GET /api/revenue.json+/dashboard.htmlโ the revenue ledger and its dashboard, private. The page itself is gated, not just the data: unauthorized requests get the ordinary 404, so its existence is never disclosed. Access is via?token=<DASHBOARD_TOKEN>once, traded for an HttpOnly session cookie. The dashboard labels testnet settlements as testnet rather than calling them revenue.revenue.js.- Everything else falls through to the
ASSETSbinding, decorated bynegotiate.js(Link:header,Accept-based content negotiation) โ the two agent-readiness checks the audit scored as impossible on static hosting. - Every request is logged to Analytics Engine with a bucketed path, a classified client type (
classify.js), method, status class, truncated UA and ASN. No IP addresses.
Source of truth (hand-edited or workflow-written):
listings/<slug>.jsonโ one file per listingtemplates/โ index.html, 404.html, llms.txt, robots.txt, openapi.yaml with{{BASE}}/{{REPO}}/{{COUNT}}/{{LISTINGS_HTML}}placeholderssite.config.jsonโ base URL + repo slug (the single knob for migration)scripts/validate.mjsโ the security boundary: field rules,validate(),reconstruct(),esc(),jsonLd(), plusschemaJson()so the published schema is generated from the same constants that enforce it
Generated by node scripts/build.mjs (committed; deterministic โ no timestamps, build twice โ zero diff): index.html, 404.html, llms.txt, llms-full.txt, robots.txt, openapi.yaml, sitemap.xml, api/index.json, api/schema.json, .well-known/agent.json (A2A card), .well-known/agents.json (the plural agents-manifest โ a different spec, read by agent-readiness auditors), .well-known/security.txt (RFC 9116; its Expires is hardcoded in the template because the build may not stamp a timestamp, and a test fails once it passes), l/*.html (the l/ dir is wiped and rebuilt so removed listings can't leave stale pages).
Plus the surfaces whose whole job is to make the routes above findable by something that only knows the domain: .well-known/mcp.json (MCP server card โ SEP-1649/2127 are draft, so it carries only the fields both drafts agree on), opensearch.xml (still the one format that turns a bare domain into a callable search box), feed.xml + feed.json (a directory that gains entries is a feed), and .well-known/ai-plugin.json (superseded, and probed often enough that answering costs less than the 404s). A test asserts the manifests cannot advertise a route the Worker does not have.
Write paths (the autonomous transactions) โ .github/workflows/register.yml, gated on issue-title prefix (not a label, which REST-API agents couldn't set):
[register]โ new listing.scripts/process-issue.mjs(input via env only, never shell-interpolated): 20 KB cap โ parse (```json fence or bare body) โvalidate()โ unique slug + normalized-URL dedup โ โค 10 listings per GitHub account โ liveness check (product URL must answer < 400 in 10 s) โ write reconstructedlistings/<slug>.json.[update]โ full replacement of an existing listing; only the originalgithub_user(or the repo owner) may update;created/github_user/tierpreserved,updatedstamped.[upgrade]โ paid tier change ({"slug", "tier": "verified|featured", "rail": "x402", "receipt": {"transaction": "0xโฆ"}}): ownership + shape checks, then on-chain receipt verification viascripts/x402-receipt.mjsโ the transaction must have succeeded, have enough confirmations, and contain an ERC-20Transferof at least the tier price in the configured asset topayments.x402_address. Spent transaction hashes are burned into the committedpayments.jsonledger so one payment cannot buy two upgrades. Rejectspayments_not_enabledwhile the rail is unconfigured;rail: "card"returnsmanual_reconciliation.- All modes: build + commit + push with a reset-and-redo retry loop ร3 (not an Actions
concurrencygroup, which silently cancels queued runs; afterreset --hardthe dedup re-runs, so a lost race fails cleanly), then a machine-readable bot comment ({"ok":โฆ,"code":โฆ,"errors":โฆ}) and issue close. Pages redeploys on the push (~1 min).
Tiers: free < verified < featured โ paid tiers sort first in the index and get a badge. Manual flip (e.g. after an out-of-band payment): node scripts/set-tier.mjs <slug> <tier>, then commit + push.
Health โ .github/workflows/health.yml (Mondays 04:17 UTC + manual dispatch): scripts/check-liveness.mjs re-checks every listing URL; strike state in committed health.json; 3 consecutive weekly failures delist (page 404s, registry updated); failures/delistings reported as a GitHub issue.
Catalog liveness โ scripts/probe-catalogs.mjs, same weekly cron: neither upstream registry checks whether its entries still answer, so a rotating sample of both catalogs is probed and the results published at api/{x402,mcp}/health.json. Every stride-th row rather than a contiguous window โ these files are sorted, so a neighbourhood is not a sample (placeholder URLs are 1.6% of the MCP catalog but were 54% of its first 120 rows) โ with the cursor advancing one per run, so a full pass still covers every entry exactly once. A 402 or 401 counts as answering: the question is whether anything is listening, and only transport failures and 5xx count against an entry. Two consecutive misses before anything is called dead; search flags and never hides, since one weekly probe from one network path is evidence rather than proof.
MCP server โ mcp/server.mjs: zero-dependency stdio JSON-RPC (initialize/ping/tools/list/tools/call). It imports its tool definitions from worker/discovery.js and forwards tools/call to the hosted /mcp, so the two servers cannot drift; tools/list stays offline because registry health checks introspect it in a sandbox with no network. Adds register_product (opens the [register] issue; needs env GITHUB_TOKEN, public_repo), which is the only reason to prefer stdio โ a token on a public Worker is a credential waiting to leak. Install: claude mcp add ai-product-index -- node <clone>/mcp/server.mjs, or skip the clone entirely with claude mcp add --transport http ai-product-index https://index.percall.dev/mcp.
Security model: all HTML text/attributes through one esc(); hrefs only from scheme-allowlisted (http/https, public-host) URL fields; JSON-LD <-escaped against </script> breakout; slug regex + resolved-path assertion stop path traversal; accepted objects rebuilt field-by-field from an allowlist (no __proto__ write-through); workflow token scoped to contents: write, issues: write.
The free/paid boundary is a whitelist, not a delete. freeView() in score.js names the fields the free tier keeps, so a field added to the audit later cannot leak into it by omission โ and a test asserts a hypothetical new paid field stays out. The free tier deliberately answers "do I have a problem, and roughly where"; the paid tier answers "here is the code that fixes it".
Payment security โ the facilitator verifies signatures and balances; it has no idea what we charge, so worker/x402.js is what stops a client from paying one atomic unit to an address of its own choosing:
- every field of the client's
acceptedblock is compared against our own requirements (scheme, network, asset,payTo, amount), and the authorization is checked independently โ a payload with a correct-lookingacceptedblock but an authorization paying elsewhere is rejected; - amounts compare as
BigInt, so"1e5"," 10000"and"-10000"never pass as"10000"; - the nonce is reserved in KV before settling, so a concurrent replay loses the race instead of settling twice;
- the audit target passes
urlError()(public http/https hosts only) before any charge โ nobody pays for a request we would reject.
Local development
node --test scripts/*.test.mjs # validator, escaping, worker, payment-gate, receipt tests
node scripts/build.mjs # regenerate everything (deterministic: build twice โ zero diff)
npx wrangler dev # serve assets + Worker routes locally
# simulate a registration without GitHub:
ISSUE_BODY='{"slug":"x-y-z","name":"X","url":"https://example.com","description":"d","category":"api","pricing":"free"}' \
ISSUE_USER=you node scripts/process-issue.mjs # SKIP_LIVENESS=1 to skip the URL check
Use scripts/*.test.mjs, not node --test scripts/ โ Node 22 resolves a bare directory argument as a module and fails before running anything.
Deployment (Cloudflare)
.github/workflows/deploy.yml runs tests, asserts the committed build is not stale, then wrangler deploy on every push to main. It skips the deploy with a notice while the two Cloudflare secrets are unset, so main stays green instead of collecting red Xs nobody reads. Deployed and green since 2026-07-25; the setup below is recorded for a rebuild, not outstanding work. One-time setup:
npx wrangler kv namespace create PAYMENTSโ put the id inwrangler.toml.- Repo secrets
CLOUDFLARE_API_TOKEN(Workers Scripts: Edit) andCLOUDFLARE_ACCOUNT_ID. - Point
index.percall.devat the Worker โgh workflow run cf-admin -f action=attach-domain -f hostname=index.percall.dev(the zone must be on the Cloudflare account and the token needs its zone rights). - Optional, for
/api/stats.json:npx wrangler secret put CF_ACCOUNT_IDandCF_ANALYTICS_TOKEN(Account Analytics: Read). Without them the endpoint reportsstats_not_enabledrather than pretending.
Locally, run it with npx wrangler dev --local --persist-to /tmp/seo-wstate. The --persist-to outside the repo matters: the asset directory is the repo root, so wrangler's state dir otherwise lands in the watched tree and reload-loops forever.
Migration knob: site.config.json โ base is the single source for every absolute URL; the build regenerates sitemap/canonical/JSON-LD/llms.txt/openapi from it. The three hardcoded URLs in .github/ISSUE_TEMPLATE/*.yml must be edited by hand (issue-form text can't be templated).
Payments โ rails, and how to switch between them
Rails differ only in where they settle and who they answer to, so they live as named profiles under payments.x402.profiles with an active selector. Moving from rehearsal to real money is one word, not five edited fields. scripts/x402-config.mjs โ resolveX402() is the single resolver; both the Worker and the [upgrade] issue flow read it, so the two can never disagree about which chain and asset are being accepted.
Currently active: "mainnet" โ Base, USDC, real money.
| Profile | Facilitator | Auth | Settles Base mainnet? | Ready? |
|---|---|---|---|---|
mainnet | PayAI | none | yes, v1 + v2 | yes โ active now |
testnet | x402.org/facilitator | none | no โ testnet only | yes โ flip active back to rehearse |
cdp | Coinbase CDP | Bearer JWT | unverifiable without keys | needs a CDP API key |
The flip to mainnet was made deliberately without first settling a testnet
payment, so the profile's correctness rests on what was checked statically: the
asset address against Circle's own page and the chain, the EIP-712 domain name
against the token's name(), and the facilitator's /supported against the
network. Reverting is the same one word.
The public x402.org facilitator cannot settle Base mainnet. Its /supported advertises eip155:84532 and no mainnet at all, and the x402 docs say plainly not to treat it as a production path โ so mainnet points at PayAI from the official facilitator directory instead: no API key, and it advertises Base mainnet under both protocol versions. A third-party facilitator relays the transaction and pays the gas; it cannot redirect funds, because the authorization is signed to our address for our exact amount.
Check any profile against the chain and its facilitator before switching to it:
node scripts/verify-rail.mjs mainnet
That reads the token's own name(), symbol(), decimals() and version() off chain and asks the facilitator what it will actually settle. It exists because two mistakes here are invisible until every payment fails: a wrong asset address, and a wrong EIP-712 domain name โ asset_name is published as the domain the payer signs against, and USDC calls itself "USDC" on Base Sepolia but "USD Coin" on Base mainnet, which is why it is a per-profile field.
Going live on mainnet was exactly that: the check above, the asset eyeballed on basescan once, "active": "mainnet", push. The live rail is now cdp, which costs a CDP API key (wrangler secret put CDP_API_KEY_ID / CDP_API_KEY_SECRET, never in site.config.json) and buys a free tier of 1,000 tx/month plus cataloging in the x402 Bazaar. CDP's /supported answers 401, so verify-rail.mjs signs it with the same code the Worker uses when those two variables are present โ gh workflow run cf-admin -f action=verify-cdp runs it on the runner, the only place the secrets exist.
Bazaar cataloging is not automatic just because the facilitator is CDP: the listing is built from discovery metadata attached to a settlement, so an endpoint can take real money indefinitely and never be listed. /api/audit therefore publishes an outputSchema with discoverable: true (v1) and the same object under extensions.bazaar (v2) โ a shape read off the live catalog rather than the docs. node scripts/bazaar-check.mjs answers whether it worked.
Prices are atomic units โ USDC has 6 decimals, so 50000 = $0.05. audit_price_atomic covers /api/audit; verified_tier_price_atomic and featured_tier_price_atomic cover [upgrade]. Agents can read the live terms at /api/x402/info without provoking a 402.
Card (humans) โ a Stripe payment link in payments.stripe_payment_link; rail: "card" upgrades answer manual_reconciliation and are flipped with scripts/set-tier.mjs. Stripe's own x402 product settles to a Stripe balance in fiat but is private preview behind an access request; adopting it later is a facilitator_url change, not a rewrite.
CDP authentication (worker/cdp-auth.js) is hand-rolled on WebCrypto rather than pulling in @coinbase/x402, which would drag viem, zod and the whole CDP SDK into a Worker for one signature. The contract was read off those published package sources: header {alg, kid, typ, nonce} with alg of EdDSA (Ed25519) or ES256 (EC), claims {sub, iss: "cdp", nbf, exp, jti, uris}. The uris claim binds each token to one method+host+path, so a /verify token cannot be replayed against /settle. A rail declaring auth: "cdp" with no credentials fails closed instead of firing unauthenticated and surfacing Coinbase's 401 as if the agent's payment were bad.
The read API will not change shape โ tier has been server-set on every listing since day one.