# 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`, no `Content-Disposition`. `?raw=1` and `/raw/{ref}/{path}` **302** to `raw.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:

- `GET` the page (gets chrome or a 303)
- `/raw`, `/source`, `?raw=1` (GitHub/gist/GitLab muscle memory)
- `/llms.txt` then follow the recipe
- MCP `fetch_page` when 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](https://llmstxt.org/) 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:

- **Mintlify:** `{page}.md` returns `text/markdown`. Also `Accept: text/markdown` on the HTML URL. “View as Markdown” / Copy page in the UI. `Link` / `X-Llms-Txt` headers advertise `/llms.txt`.
- **FastHTML / nbdev / llmstxt.org:** `page.html.md` or `page.md`.
- **GitBook / VitePress / Docusaurus plugins:** same `.md` twin 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"` or `download="{slug}.html"` so the suggested filename is not `source`
- No `target=_blank` (save, don't navigate). No JS. HTML host stays `script-src 'none'`.

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

```http
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

- Changing `/raw` to serve Markdown, or renaming `/raw`.
- `Content-Disposition: attachment` on default `/source` or `/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_page` already 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 = true`
- `app/static/viewer.css` — match `.viewer-toolbar-fullscreen`; icon alignment in 3rem bar
- `app/viewer/routes.py` — pass `download_href` / filename; honor `download=1` query on the two `/source` handlers
- `app/viewer/source.py` — attach `Content-Disposition` when flagged; keep error channel text/plain
- `app/viewer/llms.py` — spec-shaped recipe; mention `/source` not Download
- Tests: `test_page_source.py`, `test_viewer_smoke.py`, `test_viewer_security.py` (HTML still `script-src 'none'`; Markdown `/raw` still 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, `download` attribute, and versioned `...?download=1` href.
- Home / search / unlock / 404 do not.
- `GET /source` has no `Content-Disposition: attachment`; `GET /source?download=1` does, filename `{slug}.md` / `{slug}.html`, body identical.
- Locked `/source?download=1` without 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 `/raw` still 404 without R2 get.
- `/llms.txt` still tells agents to GET `/source`.

**Manual**

- Chrome/Firefox/Safari: Markdown Download saves `{slug}.md`; HTML Download saves `{slug}.html` and does not navigate the shell or break the iframe.
- Open `/source` in 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`.
