---
title: "Every Page Has a Markdown Twin: Writing a Service Agents Can Read — NanoParse Blog"
url: https://nanoparse.app/blog/2026-10-10-agent-readable-surfaces
description: "An agent that meets a new API has two questions to answer before it commits a wallet: what does this cost, and what does it do. Both are usually answered in HTM…"
---

An agent that meets a new API has two questions to answer before it commits a wallet: what does this cost, and what does it do. Both are usually answered in HTML written for a human with a browser — a pricing page, a docs page, a changelog. That works until the thing reading the page is not a human.

The payment half of that story already ran here: [a walk of the facts a paid endpoint answers before it charges](https://nanoparse.app/blog/2026-10-04-free-half-discovery). This post is the other half — how the service is written so an agent can read it without a browser, and what it hands over before the first request. Every status, byte count and header below was fetched from the live origin today, and the probe output is committed beside this post.

## The twin: every page is also Markdown

Two ways in, one implementation. Send `Accept: text/markdown` on an HTML URL and the response is the same page as Markdown. Or fetch the `.md` URL directly:

```

curl -s -H 'Accept: text/markdown' https://nanoparse.app/quickstart
curl -s https://nanoparse.app/quickstart.md
```

Both return the same bytes. The page set, with the sizes measured today:

```

page .md form bytes tokens
/ /index.md 8636 2142
/quickstart /quickstart.md 7055 1753
/litmus /litmus.md 5158 1280
/compare /compare.md 4137 1020
/terms /terms.md 5524 1377
/privacy /privacy.md 3997 997
/blog/<slug> /blog/<slug>.md 8026 1996
/blog none — negotiates in place 4699 1164
```

`bytes` is the response size; `tokens` is the `X-Markdown-Tokens` header, which the implementation defines as `ceil(chars / 4)` — an estimate by construction, and the source says so rather than dressing it up as a measurement.

The response tells the client what it received:

```

HTTP/2 200
content-type: text/markdown; charset=utf-8
content-location: /quickstart.md
vary: Accept
x-markdown-tokens: 1753
content-signal: search=yes, ai-input=yes, ai-train=yes
cache-control: public, max-age=300
```

`Vary: Accept` is the load-bearing one. Without it a shared cache serves whichever representation it saw first — HTML to the agent that asked for Markdown, Markdown to the browser that did not. Two representations of one URL have to be told apart, or the cache becomes the bug.

## A curated list, not a wildcard

Not every path has a `.md` form, and that is deliberate. Appending `.md` to a path is the obvious client trick, so the alternate list is a fixed set rather than a rewrite rule, and the comment above the list in the deployed source states the reason: a `.md` URL for a static asset or an API route would be a lie, and a lie is worse than a 404.

So `.md` on a page with no Markdown form answers 404 instead of handing back a JSON shell or an asset wearing a Markdown content type. An agent assembling URLs by hand gets a clean no it can branch on.

## The one header that lies

The listing at `/blog` is the exception that shows why the list matters. It has no `.md` URL, but it still negotiates, and the negotiated response reports a path that was never served:

```

GET /blog Accept: text/markdown -> 200 content-location: /blog.md
GET /blog.md -> 404
```

The HTML head gets this right: `/blog` renders no , because there is no alternate to point at. The header disagrees with the head. It comes from a fallback that asks for a page's `.md` path and, getting nothing back, appends `.md` to the path anyway. A client that trusts `Content-Location` over the body will mark a page it just read as missing. A response with no Markdown form should carry no `Content-Location`; this one carries a path it never served.

## The files that describe the service

Markdown fixes how a page reads. It says nothing about where the API is or what it can do. That is the job of a second set of files, each fetched today:

```

status bytes content-type path
200 4318 text/markdown /auth.md
200 1583 application/linkset+json /.well-known/api-catalog
200 1896 application/json /.well-known/mcp/server-card.json
200 595 application/json /.well-known/agent-skills/index.json
200 2475 text/markdown /.well-known/agent-skills/nanoparse/SKILL.md
200 1743 application/json /.well-known/ai-catalog.json
200 1529 application/json /.well-known/ard.json
200 1841 application/json /.well-known/agent.json
404 13 text/plain /.well-known/agent-card.json
```

### One file that lists the others

`/.well-known/api-catalog` is an RFC 9727 linkset — a JSON document whose whole purpose is to point at the rest. It anchors to the origin and carries `describedby` (the site index, the MCP server card, the AI catalog, the auth page), `service-desc` (`/openapi.json`), `service-doc` (`/quickstart`), `alternate` (`/index.md`), `sitemap`, `status` (`/health`) and `terms-of-service`. An agent that fetches this one URL never has to guess a path.

### The MCP server card

`/.well-known/mcp/server-card.json` is the machine-readable half of the hosted MCP server: transport `streamable-http`, endpoint `https://nanoparse.app/mcp`, `capabilities.tools: true`, and the two tools an MCP client will list — `nanoparse_fetch` and `nanoparse_status`. A tool description is a contract a model reads, not a page a human skims, so it is written like one: it names the price, states what happens to a page that cannot be read, and says how a list of URLs is worked through, one call per URL.

### The skill, and a digest

`/.well-known/agent-skills/index.json` advertises one skill — a `SKILL.md` that teaches the flow — and publishes a digest beside it:

```

sha256:5c59ee56c81f3192b02843de076ef3b03cd5bfeabf1a56d466a4d40446d73546
```

Fetched today, the file hashes to exactly that. This is the part worth copying. An instructions file is a dependency — the agent loads it and behaves differently because of what it says — and a dependency you can pin beats one you cannot. The digest moves the moment the instructions move, before the behaviour does, so a client can notice.

### The A2A card and the auth page

`/.well-known/agent.json` is an A2A agent card: name, endpoint, version, a `capabilities.extensions` entry declaring x402 payment support, and a `skills` array carrying its own endpoint and output modes. `/auth.md` is the prose twin of the same fact — how an agent authenticates. Its first line is the whole policy: no registration, no account, no API key, no OAuth, because payment is the authentication. Worth publishing as a file rather than a sentence on a pricing page, because an integration checklist has a slot for auth, and an empty slot is where an attempt stalls.

## One HEAD, every surface

All of it is also in a response header, so an agent that will not parse a body can still map the origin:

```

curl -sI https://nanoparse.app/quickstart | grep -i '^link'
```

```

link: </llms.txt>; rel="describedby", </.well-known/mcp/server-card.json>; rel="mcp",
</.well-known/ai-catalog.json>; rel="ai-catalog",
</.well-known/agent-skills/index.json>; rel="agent-skills",
</.well-known/api-catalog>; rel="api-catalog", </openapi.json>; rel="service-desc",
</quickstart>; rel="service-doc", </sitemap.xml>; rel="sitemap",
</.well-known/security.txt>; rel="security"
```

Nine relations on every response, including the ones carrying a body. A client that reads only headers learns where the spec is, where the tools are, and where to report a security problem, without spending a request on any of them.

## The names are not settled

The last row of the table above is the honest one: `/.well-known/agent-card.json` answers 404 while `/.well-known/agent.json` answers 200. The A2A card has no single standard path, and the other conventions here — a Markdown site index, an RFC 9727 linkset, an MCP server card, an AI/ARD catalog, an Agent Skills index — are five different proposals with five different shapes and no common root. Nothing coordinates them.

That is a reason to read the linkset rather than hardcode a list of paths, and a reason to treat a 404 on one convention as a fact about the convention, not about the service. A surface that does not exist and a surface under a different name look identical to a client that guesses.

## What a twin does not do

A Markdown twin is the page's own text in a different wrapper. It does not say whether the page is worth citing. That is a second job, and a second file. The same URL through `POST /fetch` returns the Markdown alongside Litmus, fifteen signals computed from the rendered text and the DOM — freshness, source authority, structural trust, syndication, paywall state, content density — which is what a caller reads when the question is not what a page says but whether it should be quoted. That the signals ride the render instead of a second model call is its own post, [measured value by value](https://nanoparse.app/blog/2026-10-01-litmus-signals-measured).

Two questions, two surfaces, and conflating them is how a pipeline ends up citing a page because it was readable rather than because it was right.

## The read path

Put end to end, the order an agent can take:

```

1. GET /llms.txt # the prose map, and the price
2. GET <page>.md or Accept: text/markdown # any page as Markdown
3. GET /.well-known/api-catalog # machine-readable index of the rest
4. POST /fetch {"url": "..."} # the product: Markdown + Litmus
```

The first three cost nothing and need no payment. The fourth is $0.005 USDC on Base per successful parse, charged only after a page is actually read, with no account and no API key. The endpoint is at [nanoparse.app/fetch](https://nanoparse.app/fetch), the same service answers over MCP at [nanoparse.app/mcp](https://nanoparse.app/mcp), and the index those links point at is [nanoparse.app/llms.txt](https://nanoparse.app/llms.txt).

The full probe output behind every number above — statuses, byte counts, content types, the token headers and the digest check — is committed beside this post.
