A searchable index of agent skills you can pay for. Register with a signed listing, search per query. Every listing takes USDC on Base and Solana — enforced, not encouraged.
| Route | Price | What lands in the 200 body |
|---|---|---|
POST /register | $0.01 | The signed listing record — name, description, skill.md URL, route prices, rails, expiry — plus a one-time updateKey |
GET /search?q=… | $0.001 | Ranked results with skill.md URLs, prices and rails, each carrying score and matched so relevance is explainable |
GET /skills.json | free | The whole public index — every live listing with its signature |
GET /listing/:id · POST /check-signature · GET / · /health · /skill.md · /.well-known/x402 · /openapi.json | free | Lookup, signature validation, discovery |
GET /index.html | free | A working browser checkout — connect a wallet and buy a search |
base-sepolia / baseexact · EIP-3009 transferWithAuthorization0x40252CFDF8B20Ed757D61ff157719F33Ec332402solana / solana-devnetexact · SPL transferChecked, fee sponsoredWwwuGbqHrwF5RG89KhUbmRWEvjnRH9k5kVM5p7T3WwWBoth rails appear in the accepts array of every 402. Settlement runs through
the x402 facilitator, and the 200 carries an
X-PAYMENT-RESPONSE receipt naming the rail and the transaction.
# 1. run it — seeded on first boot, so the index is not empty
npm install && npm run dev # http://localhost:4030
# 2. the index is free
curl -s localhost:4030/skills.json | jq '{count, policy}'
# { "count": 4, "policy": "Every listing here accepts USDC on both Base and Solana." }
# 3. search costs $0.001 → the dual-rail challenge
curl -s 'localhost:4030/search?q=domain' | jq '.accepts[] | {network, payTo, maxAmountRequired}'
# { "network": "base-sepolia", "payTo": "0x40252CF…2402", "maxAmountRequired": "1000" }
# { "network": "solana", "payTo": "WwwuGbqH…T3WwW", "maxAmountRequired": "1000" }
# 4. pay → ranked listings, each with the skill.md URL you need next
const pay = wrapFetchWithPayment(fetch, wallet);
const { results } = await (await pay(`${BASE}/search?q=domain&rail=solana`)).json();
results[0].skillMdUrl // "https://…/x402-domains/main/skill.md"
results[0].score // 11
results[0].matched // ["name", "category", "description", "resource"]
A listing must carry both an EVM rail and a Solana rail. POST /register returns 422 NOT_DUAL_RAIL otherwise. Not a warning, not a badge — a rejection.
An agent's wallet lives on exactly one chain. A single-rail entry is unusable to everyone on the other one, and an index whose entries you cannot pay is a list of names, not a directory. Because the rule holds for every row, GET /search never needs a “can I actually pay this” filter: the answer is always yes.
curl -s localhost:4030/skills.json | jq '.listings[].listing.rails[] | {rail, network}'
{ "rail": "evm", "network": "base-sepolia" }
{ "rail": "solana", "network": "solana" }
GET /skills.json costs nothing. A directory nobody can read is not a directory, and gatekeeping the list would defeat the point of publishing it.
What costs $0.001 is ranking and filtering — the query, the score, the rail filter, the price ceiling. That is real work on your behalf, and it is what an agent actually wants: not “here are all 400 services” but “here are the three that do what I need, on my chain, under my budget.”
The repo ships public/index.html — a real checkout, not a mockup. Serve it and a human can connect a wallet and buy a search:
<script type="module" src="https://unpkg.com/@three-ws/x402-payment-modal"></script>
<button data-x402-endpoint="/search?q=domain"
data-x402-method="GET"
data-x402-merchant="x402-skill-registry"
data-x402-action="Search the registry">Pay $0.001 & search</button>
<script>
addEventListener("x402:result", (e) => render(e.detail.data.results));
</script>
The modal reads both rails out of the 402 and offers the wallet choice itself — Phantom for Solana, any EVM wallet for Base — so nothing extra was needed beyond having two entries in accepts. It also does SIWX re-entry (sign in once, later purchases skip the prompt) and per-origin spending caps, so a page cannot quietly drain a wallet.
Every listing is HMAC-SHA256 over canonical JSON, and POST /check-signature validates one for free. But read a signature for what it is: proof that this registry recorded that listing — not that the service behind skillMdUrl behaves as described. Fetch the skill.md and judge for yourself, which is exactly why the listing carries that URL.
Seeded rows carry origin: "seed" and registered ones origin: "registered", distinguishable in every response — a seeded row is not pretending to be traction.