agent-pages

Research: viewer Download button and agent raw conventions

Download

Research: viewer Download button and agent raw conventions

Date: 2026-08-17
Status: scope only — no code, no PR
Related: D36 (toolbar), D77 (/source + /llms.txt + fetch_page), docs/research-agent-page-read-access.md

This note scopes a human Download control on Markdown and HTML viewer shells, plus how agents should keep getting published bytes through conventions they already follow. The agent half of this request is mostly already shipped. The missing product is the human save-as control, plus a few small discovery nits.


What already exists

Surface Behavior today
GET /p/{slug} Human viewer chrome. Markdown is sanitized HTML; HTML is a sandboxed iframe.
GET /p/{slug}/source Published Markdown or HTML bytes. text/markdown or text/html. No Content-Disposition. PIN via cookie, ?pin=, or X-Agent-Pages-Pin. Errors are text/plain.
GET /p/{slug}/v/{n}/source Same for an immutable version.
GET /p/{slug}/raw HTML-only iframe / fullscreen target. Markdown /raw is a styled 404 and must not hit R2.
Toolbar Shared _toolbar.html: wordmark, title, version menu, theme menu. HTML adds Open fullscreen → versioned /raw. No Download.
rel="alternate" HTML <link> plus HTTP Link header on the viewer, pointing at the versioned /source.
/llms.txt and /.well-known/llms.txt Agent recipe: prefer /source, do not POST unlock.
MCP fetch_page HTTP-GETs /source from a public URL or slug.

Unlock copy already tells agents to use /source. HTML host CSP is script-src 'none' — any Download control cannot use JavaScript on HTML pages.

/source for HTML already returns the same published bytes as /raw (same _raw_html_headers()). The formats differ only in error channel and Markdown availability.


What humans are asking for

A toolbar control labeled Download, with an icon, on both Markdown and HTML page shells, that saves the published source (raw Markdown or raw HTML) to disk.

That is GitHub's split, not a new body format:

GitHub control What it does agent-pages analog
Raw Navigate to inline bytes (raw.githubusercontent.com, Content-Type: text/plain, no Content-Disposition) /source (already shipped)
Copy raw file JS clipboard Out of scope
Download raw file JS IconButton + octicon-download (data-testid="download-raw-button") Missing
Open file in browser UI Rendered blob / preview /p/{slug} viewer

Live headers (2026-08-17):

Industry pattern: raw/source URLs stay inline so curl, fetch tools, and “view source” keep working. Download is a separate human affordance that forces a save. Putting Content-Disposition: attachment on the default /source would make browsers save instead of display and is the failure mode Kudu hit when they attached that header to a shared VFS API.

GitHub implements Download with JavaScript. We cannot copy that on the HTML host. The no-JS equivalent is an <a> whose response includes Content-Disposition: attachment plus a download filename hint.


What agents actually do

Agents do not click toolbar buttons. They fetch URLs. In order of how reliably they find published bytes:

1. Explicit source/raw path (already the v1 contract)

Coding agents given a share link try, in practice:

D77 already made /source the machine path and documented it in /llms.txt. Do not teach agents to hit a Download URL. Download is for humans. Agents should keep GETting /source.

Naming trap: our /raw is HTML-iframe-only. GitHub/GitLab/gist /raw means “published bytes for any file.” Markdown /raw 404s by design (D77 out-of-scope: “Changing /raw to serve Markdown”). Agents that guess /raw on a Markdown page still fail; /llms.txt and rel=alternate are how they recover. Do not collapse /raw into /source.

2. llms.txt (already present; not spec-shaped)

llmstxt.org v2 wants:

  1. H1 site name (we have this)
  2. Blockquote summary (missing)
  3. Optional preamble (we have a recipe, but not as lists)
  4. H2 sections of markdown links - [name](url): note (missing; we use indented prose)
  5. Pages also expose a markdown twin at {url}.md or {url}.html.md
  6. rel="alternate" type="text/markdown" (we have this, pointing at /source)
  7. rel="describedby" → the covering llms.txt (missing on viewer responses)

We also serve /.well-known/llms.txt. The spec prefers path-scoped /llms.txt over well-known; keeping both is fine.

llms-full.txt (concatenate every page body) is a non-goal: PIN-locked bodies, 5 MiB pages, and a public ledger do not belong in one unauthenticated blob.

3. .md suffix (strongest automatic convention we do not have)

Docs platforms that agents actually scrape:

An agent that only knows “append .md” will request /p/{slug}.md today and get a slug miss (foo.md is not a valid allocated slug; slugs are [a-z0-9-]+ plus an 8-char suffix and contain no dots).

D77 rejected Accept-only negotiation on /p/{slug} because fetch tools send Accept: text/html. A distinct .md URL does not have that problem. HTML pages have no Markdown twin — do not lie at /p/{slug}.md for HTML.

4. Other conventions (do not take for this feature)

Convention Used by Fit
Accept: text/markdown on the human URL Mintlify Rejected for /p/{slug} in D77; optional later, not required for Download
GitHub Accept: application/vnd.github.raw REST contents API Wrong API shape; we are not GitHub
gist raw.githubusercontent.com/{id}/raw/{file} gist We have /source
Wikipedia ?action=raw MediaWiki Query-on-canonical-URL; conflicts with ?pin= and theme next discipline
MCP resource / fetch_page this product Already the configured-agent path
A2A agent-card.json, skill.md Mintlify 2026 v1.1 product expansion, not Download

Recommended v1 shape

Two tightly scoped changes. Do not invent a sixth body store.

A. Human Download control (the feature)

Where: Markdown and HTML page toolbars only (markdown.html / html_host.html). Not home, search, unlock, errors, mermaid helper, or /raw.

Control: a same-origin <a> in _toolbar.html, next to (HTML) Open fullscreen:

Response: GET /source?download=1 (and /v/{n}/source?download=1) returns the same bytes and CSP as /source, plus:

Content-Disposition: attachment; filename="{slug}.md"

Use the slug (already [a-z0-9-]+) as the ASCII filename. Optional RFC 5987 filename* from a sanitized title can wait. Do not put attachment on /source without the flag.

?pin= remains valid and orthogonal (?download=1&pin=NNNNNN). After a browser unlock, the page-scoped cookie is enough; the toolbar href must not embed the PIN.

Why both download and Content-Disposition: Chrome/Firefox honor the HTML download attribute over inline content for same-origin URLs. Safari has historically ignored download for displayable types (text/html). Attachment on the flagged response is what makes HTML saves reliable. Curl/fetch ignore Content-Disposition and still print the body — but agents should keep using unflagged /source.

Toolbar density: HTML already has version + theme + Open fullscreen inside a 3rem nowrap dock. Adding icon+label is tight below ~34rem (wordmark already hides). Keep the word “Download”; do not steal space by turning Open fullscreen into an icon in the same change.

B. Agent disclosure (small, additive)

Keep /source as the fetch target. Tighten discovery so an agent that never reads our custom recipe still lands on bytes:

  1. /llms.txt structure — add the spec blockquote; turn the recipe into H2 lists with markdown links to /p/{slug}/source as a pattern, plus a note that Download is a human Save-As on the same bytes. Do not list every ledger page (that is the public index, and PIN-locked bodies must not be bulk-exported).
  2. rel="describedby" — add /llms.txt to the existing viewer Link header (and a <link> in the Markdown/HTML host head). llmstxt.org recommends this; Mintlify additionally sends X-Llms-Txt (optional, not required).
  3. Optional but high leverage: GET /p/{slug}.md and GET /p/{slug}/v/{n}.md as aliases of Markdown /source (inline, not attachment). HTML pages: 404 or ignore — there is no Markdown twin. Register the more-specific route before /p/{slug}.

Do not implement Accept negotiation on /p/{slug} in this change (D77).


Explicitly out of scope


Implementation sketch (when building)

Touched files (expected):

Handlers stay sync def. Download is not a new lifecycle gate: it is /source with one header. PIN, expiry, private, no-store, and Vary: Cookie stay as on /source.


Test plan (when building)

Automated

Manual


Suggested decision

ID (proposed) Choice
D78 Viewer Download is a no-JS toolbar <a> on MD+HTML page shells. It GETs versioned /source?download=1 with Content-Disposition: attachment and a slug+extension filename. Default /source stays inline for agents. /raw remains HTML iframe/fullscreen only. /llms.txt + rel=alternate remain the agent contract; optionally add rel=describedby and Markdown {slug}.md aliases.

The Download button is a small toolbar + header change. Automatic agent access is already /source; the .md alias is the only extra convention that would make “append .md like GitHub/Mintlify docs” work without reading llms.txt.