# 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: its `guides_source`
> field 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
```

```bash
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. Answers `interpretations` (what the *shape* allows) and `matches` (what the *index* found), one row per chain, each with the wallet that owns it **there**, its `agent_ids`, `final_score` and the `detail_route` to 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 is `200` with `matches: []`, never `404`; only a query with none of the accepted shapes is `422` with `accepted_shapes`.
- `GET /wallets/{wallet}/chains` — per-chain ERC-8004 identities and reputation for one wallet: `agent_ids`, `final_score`, `total_reviews`, `distinct_raters`, plus `caveats[]` (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.md>
- `GET /health` — liveness plus per-chain index coverage
- `GET /pricing` — every tier, its price, and the reason for it
- `GET /manifesto` — the manifesto as data: principles wired to the code that enforces them
- `GET /chains` and `GET /chains/{network}` — per-chain aggregates: which chain to look at before paying to look at a subject
- `GET /feed` — the newest ratings across every chain, newest first
- `GET /leaderboard` — the first page (100 rows). Takes **no** parameters: a query param answers 422 pointing at `GET /leaderboard/page`, the metered paging route
- `GET /stats/facets` — the index-wide facet table
- `GET /stats/types` — breakdown by declared type
- `GET /stats/issuers` — **who wrote the index.** Ratings grouped by the host of their `feedbackURI`, with `distinct_agents` and `distinct_raters` per issuer, plus a `no_host` row 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/redoc>
- `POST /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.

1. **Ask for the free list and keep the candidates.** `GET /leaderboard` carries
   `final_score`, `shrunk_score` and `distinct_raters` in the same row, so you can
   discard without paying.
2. **Buy the composition of every finalist.** `GET /reputation/wallet/{wallet}`.
3. **Apply the five rules below. Do not look at the number.**
4. **Read the facets of the same object** — the receipt is reused for the same
   canonical `resource` (`X-Payment-Reused`).
5. **If you need it: history and evidence.** Before using `/history`, read
   `coverage.undated_reviews`: if it is high, the series is no good for deciding.
6. **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:**

```json
"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}/chains` the list is a SUBSET** — only
  what is computable from the public aggregate (today: `burn-address`). An empty
  `caveats[]` there does not promise that the paid decomposition is quiet.

---

## Paying with x402

1. **Read the challenge.** It carries `amount`, `token`, the recipient and
   `supportedChains`. Take the values from there, **never** from a cached table.
2. **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 carries
   `recipient`, `amount`, `token` and `supportedChains`, plus `accepts[]` (one
   entry per network, each with its own `payTo`), `pricing`, `next_action`,
   `price_usd`, and, for a wallet-subject route, `free_preview`. Pin the
   address in your own code.
3. **Sign an EIP-3009 `TransferWithAuthorization`** for that amount, to that
   recipient, on one of those chains, base64-encoded.
4. **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:

```js
String(parseFloat(x.toFixed(2)))   // JavaScript
```

```python
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 `null` and the index refuses to collapse them.
- **`declared_type` is not a type.** 283,770 of 470,064 agents (60.4 %, measured
  2026-08-30) are `unknown`, 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 from
  `GET /stats/types`.)*
- **`tag1` is 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 > 0`** means the last point of the series can legitimately sit
  below the profile's `final_score`. Neither is a bug.
- **The leaderboard does not order by average.** It orders by the Bayesian mean.
  `shrunk_score` and `distinct_raters` travel in the response so the order can be
  recomputed by hand.
- **The policies are versioned separately.** `policy_version` is the score's.
  `ordering_policy`, `rater_weight_policy` and `confidence_policy` do not move a
  single score; merging them would mark every rating as recomputed each time a
  list changes. `GET /health` is 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/types` fails 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:

  ```bash
  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: **a `200` with an `Age` header 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 `402` short-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:

```bash
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.

```json
{
  "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:

```bash
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 `.md` file.** Zero infrastructure change, zero added latency,
  and CloudFront already serves `.md` as `text/markdown; charset=utf-8`. Cost:
  storage measured in kilobytes.
- **A CloudFront Function on viewer-request** rewriting the URI when `Accept`
  contains `text/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 adding
  `Accept` to 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 the `Origin`
  header on `/leaderboard` and `/stats/*`, where the edge cached without `Origin`
  in 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 schema
- `https://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 do
- `https://describe.net/a2a.md` — the A2A door: `message/send` over JSON-RPC, the agent card, and what it answers
- `https://describe.net/skill.md` — the umbrella skill: the whole service as an installable playbook
- `https://describe.net/workflows.md` — satellite workflows that build on the umbrella skill
- `https://describe.net/.well-known/agent-skills/vet-a-counterparty/SKILL.md` — the focused vet loop: from `describe_pricing` to a citable receipt, with the checks in between
- `https://describe.net/.well-known/api-catalog` — RFC 9727 linkset
- `https://describe.net/llms.txt`
