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):
- GitHub raw:
content-type: text/plain; charset=utf-8, noContent-Disposition.?raw=1and/raw/{ref}/{path}302 toraw.githubusercontent.com. - GitLab snippet
/raw:content-type: text/plain,content-disposition: inline. - Mintlify
{url}.md:content-type: text/markdown,content-disposition: inline. - Wikipedia
?action=raw:content-type: text/x-wiki, inline.
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:
GETthe page (gets chrome or a 303)/raw,/source,?raw=1(GitHub/gist/GitLab muscle memory)/llms.txtthen follow the recipe- MCP
fetch_pagewhen this instance is configured
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:
- H1 site name (we have this)
- Blockquote summary (missing)
- Optional preamble (we have a recipe, but not as lists)
- H2 sections of markdown links
- [name](url): note(missing; we use indented prose) - Pages also expose a markdown twin at
{url}.mdor{url}.html.md rel="alternate" type="text/markdown"(we have this, pointing at/source)rel="describedby"→ the coveringllms.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:
- Mintlify:
{page}.mdreturnstext/markdown. AlsoAccept: text/markdownon the HTML URL. “View as Markdown” / Copy page in the UI.Link/X-Llms-Txtheaders advertise/llms.txt. - FastHTML / nbdev / llmstxt.org:
page.html.mdorpage.md. - GitBook / VitePress / Docusaurus plugins: same
.mdtwin idea.
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:
- Visible label Download
- Inline SVG icon (pagination precedent: stroke
currentColor,aria-hidden, no webfont). GitHub's download octicon is the right metaphor; draw a small tray+arrow in the same 16× style rather than adding an icon package. - Accessible name is the visible word “Download” (do not icon-only; ledger lock sigils always keep a text label)
href= versioned/p/{slug}/v/{n}/source?download=1(same immutability rule as Open fullscreen)download="{slug}.md"ordownload="{slug}.html"so the suggested filename is notsource- No
target=_blank(save, don't navigate). No JS. HTML host staysscript-src 'none'.
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:
/llms.txtstructure — add the spec blockquote; turn the recipe into H2 lists with markdown links to/p/{slug}/sourceas 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).rel="describedby"— add/llms.txtto the existing viewerLinkheader (and a<link>in the Markdown/HTML host head). llmstxt.org recommends this; Mintlify additionally sendsX-Llms-Txt(optional, not required).- Optional but high leverage:
GET /p/{slug}.mdandGET /p/{slug}/v/{n}.mdas 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
- Changing
/rawto serve Markdown, or renaming/raw. Content-Disposition: attachmenton default/sourceor/raw.- JS blob downloads, clipboard “Copy raw”, or any HTML-host script.
llms-full.txt, A2A agent cards,skill.md.- Admin
GET /v1/pages/{id}/content(publish bearer bypass). - Filename-from-title with unicode / slash sanitization (nice follow-up).
- Download on unlock / ledger rows.
- Teaching MCP a new tool —
fetch_pagealready returns the bytes.
Implementation sketch (when building)
Touched files (expected):
app/templates/viewer/_toolbar.html— Download<a>gated like fullscreen (download_href/show_download)app/templates/viewer/markdown.html+html_host.html—show_download = trueapp/static/viewer.css— match.viewer-toolbar-fullscreen; icon alignment in 3rem barapp/viewer/routes.py— passdownload_href/ filename; honordownload=1query on the two/sourcehandlersapp/viewer/source.py— attachContent-Dispositionwhen flagged; keep error channel text/plainapp/viewer/llms.py— spec-shaped recipe; mention/sourcenot Download- Tests:
test_page_source.py,test_viewer_smoke.py,test_viewer_security.py(HTML stillscript-src 'none'; Markdown/rawstill 404s without R2)
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
- Markdown and HTML viewer HTML contain a Download link with visible text, SVG icon,
downloadattribute, and versioned...?download=1href. - Home / search / unlock / 404 do not.
GET /sourcehas noContent-Disposition: attachment;GET /source?download=1does, filename{slug}.md/{slug}.html, body identical.- Locked
/source?download=1without PIN still 401 text/plain and does not leak the body. - Cookie after unlock allows download without
?pin=. - Historical version downloads v1 bytes after a v2 publish.
- HTML host CSP unchanged (
script-src 'none'). - Markdown
/rawstill 404 without R2 get. /llms.txtstill tells agents to GET/source.
Manual
- Chrome/Firefox/Safari: Markdown Download saves
{slug}.md; HTML Download saves{slug}.htmland does not navigate the shell or break the iframe. - Open
/sourcein a tab still displays (or shows as text), not a forced save.
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.