describe — API and MCP, for agents
Markdown twin of https://describe.net/api.html. Same content, no chrome. Canonical HTML: https://describe.net/api.html
This same guide is also published at https://docs.describe.net/guides/api.md (rendered beside it as
guides/api.html). It is not a second version: the documentation hub COPIES this file byte for byte — a test in the repo compares the bytes, so a copy that stops being verbatim turns the suite red. If the two ever do differ, the hub is the one that is behind, and https://docs.describe.net/build.json says by how much: itsguides_sourcefield names the commit its copy came from. The full documentation index — every guide, the frozen schema, Swagger and ReDoc — is https://docs.describe.net/.
describe is an ERC-8004 reputation index over every chain we read. It does not sell you a score — the chain is public and you can average it yourself. It sells you what a score is made of: how many distinct counterparties wrote it, how many times each one spoke, in which facet, dated on-chain, each with its transaction.
The offer, in four lines
| What you buy | Composition. Distinct raters, concentration, self-rating gap, per-facet scores, per-chain split, activity — in one response, with the policy_version that produced it. |
| What it costs | 0.01 USDC per wallet lookup. The full vet (lookup + history + every rating with its transaction) is 0.06. A citable, replayable receipt adds 0.05. Reconnaissance is free. |
| How you pay | x402. No account, no API key, no signup. The first call answers 402 with the challenge; you sign an EIP-3009 USDC authorization and repeat the same call. The facilitator pays the gas. |
| Why it is worth it | The full vet is 0.12 % of a 50 USD bounty. Doing it yourself is hours of RPC across every chain, and it still leaves you resolving ownership and inventing a scoring policy. |
Prices in this file are a snapshot. The authority is GET /pricing, and above
that, the 402 challenge of your own call — the price can depend on the
parameters, which a per-route table cannot express.
Start here: the minimum
base_url https://api.describe.net
openapi https://api.describe.net/openapi.json
auth none — no keys, no registration, no OAuth
payment x402 · EIP-3009 · USDC · the facilitator pays the gas
treasury (pinned) 0xe4dc963c56979E0260fc146b87eE24F18220e545
curl https://api.describe.net/health
curl https://api.describe.net/pricing
curl https://api.describe.net/leaderboard
curl https://api.describe.net/stats/facets
curl https://api.describe.net/reputation/wallet/0x715dc035ffb97dd7bb4095c6670138ba05bb4e6d
The first four are free and answer 200. The fifth answers 402 with its
challenge — amount, token, recipient and supported chains. That is the intended
path, not an error.
Free
GET /search/{query}— the one box: paste what you have. An EVM address, a Solana base58 id, or an ERC-8004 agent id. Answersinterpretations(what the shape allows) andmatches(what the index found), one row per chain, each with the wallet that owns it there, itsagent_ids,final_scoreand thedetail_routeto pay for its decomposition. It never answers in the singular, and that is not caution: the same numeric id exists on up to ten chains at once and — measured 2026-08-23 over 26,736 ids present on two or more chains — not one has the same owner on all of them. In Solana a wallet and a Metaplex Core asset are the same syntax, so a base58 string gets both readings, labelled, without the route picking one. Exact match only: no prefix, no wildcard, no listing, no paging, no name search. A well-formed query the index has never seen is200withmatches: [], never404; only a query with none of the accepted shapes is422withaccepted_shapes.GET /wallets/{wallet}/chains— per-chain ERC-8004 identities and reputation for one wallet:agent_ids,final_score,total_reviews,distinct_raters, pluscaveats[](see "Caveats"). The preview before paying for/reputation/wallet/{wallet}— if nothing is here, there is nothing to buy.GET /badge/{wallet}.svg— one embeddable SVG per wallet: the global score, how many distinct raters it is made of, and the date of the data, inside the pixel. An<img>, not a widget: it cannot script, cannot track and cannot be clickjacked. Cached an hour at the edge. Full guide: https://describe.net/badge.mdGET /health— liveness plus per-chain index coverageGET /pricing— every tier, its price, and the reason for itGET /manifesto— the manifesto as data: principles wired to the code that enforces themGET /chainsandGET /chains/{network}— per-chain aggregates: which chain to look at before paying to look at a subjectGET /feed— the newest ratings across every chain, newest firstGET /leaderboard— the first page (100 rows). Takes no parameters: a query param answers 422 pointing atGET /leaderboard/page, the metered paging routeGET /stats/facets— the index-wide facet tableGET /stats/types— breakdown by declared typeGET /stats/issuers— who wrote the index. Ratings grouped by the host of theirfeedbackURI, withdistinct_agentsanddistinct_ratersper issuer, plus ano_hostrow for the ratings that carry no URI. Free on purpose, and the reason is uncomfortable: this is the route somebody audits us with — how much of the index was written by products of our own house. Advisory, and it never moves a score.GET /openapi.json— the schema; Swagger UI at https://api.describe.net/docs, ReDoc at https://api.describe.net/redocPOST /a2a— the same free preview over A2A JSON-RPC (message/send), for an agent that speaks A2A rather than HTTP. Agent card at/.well-known/agent-card.json. Full guide: https://describe.net/a2a.md
Metered
| Route | USD | What it answers |
|---|---|---|
GET /reputation/wallet/{wallet} |
0.01 | Reputation of a wallet on every chain we read, with its composition |
GET /reputation/wallet/{wallet}/history |
0.03 | How the score moved, dated by on-chain time |
GET /reputation/agent/{network}/{agent_id} |
0.02 | One agent and every rating with its txHash, revoked ones included and marked |
GET /reputation/rater/{wallet} |
0.01 | The other side of the grain: the wallet as a rater. Totals emitted, distinct subjects, dispersion, per-chain split, favourite-subject share, role breakdown from tag1. It emits no score — ranking raters would be a new verdict |
GET /reputation/wallet/{wallet}?snapshot=true |
0.05 | Writes a durable receipt row with inputs_digest and policy_version |
GET /leaderboard/page |
0.01 | Paging with offset, widening limit (≤500) or filtering by type — every call |
/reputation/rater/{wallet} is the one metered route with no free_preview
in its 402, and the absence is deliberate: /wallets/{wallet}/chains previews
the wallet as a subject, which is a different question from the wallet as a
rater. Offering it there would preview an answer to a question you did not
ask.
Why the split: free is what one cached run serves everybody — the aggregate index
has exactly one answer per cache window — plus the number and the location of a
named subject (/search/{query}, /wallets/{wallet}/chains), which come off
materialised views. Metered is the decomposition of that subject: who rated it,
how many times each one spoke, in which facet, dated, with its transaction — where the
key space is the whole index and the long tail never hits a cache. Until 2026-08-23
this paragraph drew the line at "the question about one named subject"; that was
already inexact for the free per-chain gate, and /search moved it for good.
Charging for /search would also mean billing typos: the paywall runs before any
validation, so a mispasted string quotes exactly like a good one.
The decision loop
The window that matters is between GET /tasks/{id}/applications and
POST /tasks/{id}/assign — routes on your own task marketplace (e.g.
Execution Market), not on describe. After the assign, the EIP-3009 signature
has already picked the worker — the nonce includes the receiver — and the money
is committed.
- Ask for the free list and keep the candidates.
GET /leaderboardcarriesfinal_score,shrunk_scoreanddistinct_ratersin the same row, so you can discard without paying. - Buy the composition of every finalist.
GET /reputation/wallet/{wallet}. - Apply the five rules below. Do not look at the number.
- Read the facets of the same object — the receipt is reused for the same
canonical
resource(X-Payment-Reused). - If you need it: history and evidence. Before using
/history, readcoverage.undated_reviews: if it is high, the series is no good for deciding. - If you owe somebody an account:
?snapshot=true. What you buy is not the number, it is the commitment to the number.
The five rules
| If in the response… | Then | Why |
|---|---|---|
concentration == null |
do not decide with this | Not "no concentration" — the signal is degrading. The API degrades the secondary aggregate rather than failing the whole response. |
distinct_raters < min_raters |
no evidence, which is not zero | 7,577 of 15,763 scored wallets have exactly one rater, and 22 % of those average a flat 100 — against 1 % among those with three (measured 2026-08-10). |
total_reviews / distinct_raters >= campaign_per_rater |
campaign: discard | Over the 3,000 best-ranked wallets the median is 1.00 and the p90 is 2.0. Careful: top_client_share alone does not catch this — the largest campaign in the index has a share of ~0.14 (measured 2026-08-11). |
top_client_share >= top_share |
one single voice | Agent base/888 has 1,200 ratings where one rater wrote 98.4 %. It scores 99.23 (measured 2026-08-11). |
self_rated.gap > 15 |
it overrates itself | Self-rating stays out of the average but is published with its gap. This cut is yours: reading_policy.self_gap is null because the index publishes the gap and does not judge it. |
The names in the left column are fields of reading_policy, in the free
GET /health — the same object the paid endpoint reads to fill caveats[].
They are named rather than typed here on purpose: until 2026-08-26 this table
carried its own copies and they had already drifted from the code that charges
for them. Each rule fires on its own (reading_policy.combine is
"independent"); requiring two at once is a blind filter, not a strict one.
These five rules are not a traffic light. They filter what is grossly broken. The facets are where you look once a candidate passed the filter.
caveats[] — the same rules, already fired
Every response that carries a score carries caveats[]: the traps these
numbers trigger, written out, derived from the fields of that same response and
never from a second query — which is why it costs nothing extra.
Since 2026-08-28 each entry is an object, not a string:
"caveats": [
{ "code": "burn-address",
"text": "Esta wallet es una direccion de quema bien conocida: …" }
]
(measured live on the free door, 2026-08-30:
curl https://api.describe.net/wallets/0x000000000000000000000000000000000000dead/chains)
The contract, and it is the point of the field: text may change without
notice — be reworded, re-measured, even translated. code never changes.
Branch on caveats[].code, never on caveats[].text. The codes carry no
version and do not bump policy_version, because a caveat is advisory by
construction: a code names a cut, it does not move a score or a price.
The eight codes are the whole set, and it is frozen by a test — adding or renaming one is deliberately red:
code |
Fires when |
|---|---|
no-score |
there is no score to read; null, never zero |
concentration-degraded |
concentration came back null — the signal is down, not absent |
single-rater |
exactly one distinct rater |
few-raters |
below reading_policy.min_raters |
top-client-share |
at or over reading_policy.top_share |
campaign-per-rater |
at or over reading_policy.campaign_per_rater ratings per rater |
self-rated |
the subject rated itself; the gap is published, not judged |
burn-address |
the subject is a known burn address — real on-chain ratings about something nobody controls |
Two things that bite:
- An empty list is not "verified clean." It means no trap fired on the fields present in this response.
- On the free
GET /wallets/{wallet}/chainsthe list is a SUBSET — only what is computable from the public aggregate (today:burn-address). An emptycaveats[]there does not promise that the paid decomposition is quiet.
Paying with x402
- Read the challenge. It carries
amount,token, the recipient andsupportedChains. Take the values from there, never from a cached table. - Verify the recipient against the pinned one. The only address this service
ever asks to be paid at is
0xe4dc963c56979E0260fc146b87eE24F18220e545. If the challenge names another address, do not pay: either it did not come from describe, or the treasury changed and the server did not find out. Raw HTTP does not make this comparison for you — the body of the 402 carriesrecipient,amount,tokenandsupportedChains, plusaccepts[](one entry per network, each with its ownpayTo),pricing,next_action,price_usd, and, for a wallet-subject route,free_preview. Pin the address in your own code. - Sign an EIP-3009
TransferWithAuthorizationfor that amount, to that recipient, on one of those chains, base64-encoded. - Repeat the SAME call with the header. Same arguments, one more field. The nonce is consumed on settlement, so a credential that already paid does not pay twice. A 4xx after paying is almost always a spent credential or one signed for a different amount: ask again without payment, read the new challenge, sign against that one.
Displaying a score — the canonical format
Two decimals, trailing zeros trimmed. 86.65, 84.7, 87 — never 82.0.
Fixed on 2026-08-29 after the ecosystem's three consumers each rendered the
same number differently (86.653045, 86.7, 86 — one field, three strings),
and decided by measurement, not taste: over 47 real distinct scores, rounding to
0 decimals merges 23 pairs of different agents into identical strings, 1
decimal merges 4, 2 decimals merges 1. And trimming matters as much as the
count: two surfaces both "agreeing on 1 decimal" still printed 82.0 and 82
for the same agent.
The canonical one-liner, byte-identical across the two:
String(parseFloat(x.toFixed(2))) // JavaScript
f"{round(x, 2):g}" # Python
Pin it with one test against these cases: 86.653045 → "86.65",
84.7 → "84.7", 87 → "87", 82.0 → "82".
Live witness case (verified across three independent surfaces on
2026-08-29): the agent scored 83.0 renders as 83 on all three — where
toFixed(2) alone would print 83.00 and toFixed(1) would print 83.0.
It is the one value that tells the three candidate rules apart, which is why
it is the case to test first.
This is a display convention: the API keeps serving the full-precision number (six decimals), and what you compute with is the number, never the string.
Measured traps
- No ratings ≠ zero. "There is no evidence" and "they were rated badly" are
different facts. The field arrives
nulland the index refuses to collapse them. declared_typeis not a type. 283,770 of 470,064 agents (60.4 %, measured 2026-08-30) areunknown, and the second largest "type" is the URL of the EIP schema — with its typo variant. ERC-8004 has no type field. Never treat it as verification. (Until 2026-08-30 this line read "69,517 of 82,835 agents (84 %, measured 2026-08-11)". That was true when it was written and it stayed legal — it carried its date — but the index has grown 5.7× since, and the share had moved 24 points. The correction stays written: a dated figure does not stop being stale, it only stops being a lie. Read it live fromGET /stats/types.)tag1is free text on-chain. The longest facet in this index is 471 characters: a paragraph about gardening used as a label. Escape everything that comes from the chain.undated_reviews > 0means the last point of the series can legitimately sit below the profile'sfinal_score. Neither is a bug.- The leaderboard does not order by average. It orders by the Bayesian mean.
shrunk_scoreanddistinct_raterstravel in the response so the order can be recomputed by hand. - The policies are versioned separately.
policy_versionis the score's.ordering_policy,rater_weight_policyandconfidence_policydo not move a single score; merging them would mark every rating as recomputed each time a list changes.GET /healthis the authority on how many there are — this line has already been wrong once, in the same batch that added the fourth.
What is broken
Honesty about our own state, because a reputation index that makes up its own status has no authority to decompose anybody else's.
-
~~
GET /stats/typesfails at the origin after ~25 s~~ — does not reproduce; corrected 2026-08-30. The bullet is kept, struck through, because deleting it would hide that this page published a false status about itself — which is the one thing a reputation index cannot afford to do quietly. It was re-measured with exactly the discriminating check the bullet itself asked for — a cache-buster, so the edge cannot answer for the origin:curl -s -o /dev/null -D - "https://api.describe.net/stats/types?cb=$RANDOM$RANDOM"Three consecutive runs on 2026-08-30, every one
X-Cache: Miss from cloudfront(so every one went to the origin): HTTP 200 in 0.229 s · 0.190 s · 0.198 s, full body,total_agents: 470064. The route is on the critical path again. The lesson survives the bug: a200with anAgeheader proves nothing about the origin — that check is still how you tell a live route from a warm cache, here and anywhere else on this API. -
Cold latency is seconds, not milliseconds. The
402short-circuits before any SQL runs, so the challenge comes back fast; the paid response does the work. Ask for the vet before you open the assignment window, in parallel with everything else — not inside it.
The live version of this list is measured in your own browser, per endpoint, at https://describe.net/api.html#status.
MCP
Stdio tools wrapping the same endpoints, priced like the routes behind them:
the tools that wrap free routes are free, the tools that decompose a named
subject are metered. describe_pricing is free and explains all the others;
describe_check_wallet is also free and answers GET /wallets/{wallet}/chains
— whether a wallet has anything worth paying for, before you spend on
describe_lookup_wallet. When you do not even know what you are holding,
describe_resolve is free too and answers GET /search/{query}: what the string
was read as, and one row per chain with the wallet that owns it there.
Fourteen tools (measured 2026-08-30 with tools/list against the live server):
six are priced like the routes behind them; the other eight never charge —
leaderboard and facets also have free variants (first page / no wallet). (This
line said "thirteen: five priced" until 2026-08-30. It was counted on 2026-08-23
and was true then; describe_rater_profile shipped on 2026-08-29 and no count
written in prose turns red on its own.) Reproduce it yourself in one call:
curl -s -X POST https://api.describe.net/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
The server card is the live list — this count is dated, the card is not; call
describe_pricing first and it tells you what exists.
{
"mcpServers": {
"describe-net": {
"command": "python",
"args": ["/absolute/path/to/the/repo/mcp/server.py"],
"env": { "DESCRIBENET_API_URL": "https://api.describe.net" }
}
}
}
That /absolute/path/to/the/repo is literal: the repository is not published, so
this config only works if you already have the code. In the meantime the tools
do nothing the GET calls above cannot — the server is a wrapper, not a capability. It
does not sign, does not custody, cannot move funds, and computes nothing: no scoring
rule lives there, every tool calls the canonical endpoint. Server card:
https://describe.net/.well-known/mcp/server-card.json
WebMCP
If you visit api.html with a browser, seven tools are registered on the page
via navigator.modelContext.provideContext(): describe_net_index_status,
describe_net_top_wallets, describe_net_price_quote, describe_net_check_wallet,
describe_net_lookup_wallet, describe_net_wallet_by_chain,
describe_net_open_profile. describe_net_check_wallet is free and answers whether
a wallet has anything worth paying for. The metered ones return the 402 challenge
with the recipient already compared against the pinned treasury; they cannot sign
and hold no keys.
Twin files AND content negotiation — you have both
Content negotiation works. Ask for /, /index.html or /api.html with
Accept: text/markdown and you get the markdown twin:
curl -s -o /dev/null -w '%{content_type}\n' -H 'Accept: text/markdown' https://describe.net/api.html
# text/markdown; charset=utf-8 (measured 2026-08-30)
curl -s -o /dev/null -w '%{content_type}\n' https://describe.net/api.html
# text/html (control, same URL, no header)
The response also carries Vary: Accept, Accept-Encoding, which is what keeps a
third-party cache from serving one to the other.
(Correction, 2026-08-30: until today this section opened with "Accept: text/markdown on api.html returns HTML … S3 does not negotiate content",
and it was wrong from 2026-08-21 onward — the day the CloudFront Function
shipped. llms.txt had said the opposite, on this same site, for nine days.
The line mattered more than most: it is the one an agent reads before deciding
not to try. The analysis below is kept unchanged, because it is still why the
feature was built the way it was.)
The twins also stay at fixed URLs (/api.md, /auth.md, /skill.md,
/workflows.md, /badge.md, /a2a.md, /index.md, /manifesto.md), and that
remains the most reliable way to fetch them — no header, no negotiation, no
cache subtleties. The two mechanisms coexist on purpose:
- A twin
.mdfile. Zero infrastructure change, zero added latency, and CloudFront already serves.mdastext/markdown; charset=utf-8. Cost: storage measured in kilobytes. - A CloudFront Function on viewer-request rewriting the URI when
Acceptcontainstext/markdown. Costs about USD 0.10 per million invocations — which is not the real cost. The real cost is the cache: naïvely this requires addingAcceptto the cache key, and getting that wrong poisons it — whoever warms an object first decides what everybody else receives, HTML or Markdown, at random. This site has already eaten that exact bug once with theOriginheader on/leaderboardand/stats/*, where the edge cached withoutOriginin the key and browsers got a response with no CORS header. Splitting the cache key also halves the hit rate for every object. What ships instead rewrites the URI before the cache key is computed, so the agent and the browser land on two different objects and neither can be served the other's bytes. That is the whole trick, and it is why the twin files had to exist first: only a path with a real twin is ever rewritten — promising markdown for a page with no twin would be an announced 404.
Discovery for this file: <link rel="alternate" type="text/markdown" href="/api.md">
in the HTML head, plus /llms.txt.
Related
https://docs.describe.net— the documentation hub: this guide and the others, plus the rendered OpenAPI (Swagger and ReDoc) and a citable snapshot of the schemahttps://describe.net/auth.md— how to authenticate (you do not; payment is the auth)https://describe.net/badge.md— the embeddable badge: one SVG per wallet, how to embed it and what it does not dohttps://describe.net/a2a.md— the A2A door:message/sendover JSON-RPC, the agent card, and what it answershttps://describe.net/skill.md— the umbrella skill: the whole service as an installable playbookhttps://describe.net/workflows.md— satellite workflows that build on the umbrella skillhttps://describe.net/.well-known/agent-skills/vet-a-counterparty/SKILL.md— the focused vet loop: fromdescribe_pricingto a citable receipt, with the checks in betweenhttps://describe.net/.well-known/api-catalog— RFC 9727 linksethttps://describe.net/llms.txt
Verbatim source: api.md · from site/api.md at git:b5eb83903e36 · back to the documentation portal