llms.txt

I was watching Claude Code fetch one of my blog posts. It grabbed the HTML, stripped all the tags, and tried to reconstruct something readable. The result was close to the markdown I wrote the post in. Hugo already has the source. Why make an agent reconstruct what’s already there?

There’s a spec for this called llms.txt. Same idea as robots.txt but for LLMs. Put a text file at /llms.txt that tells agents what the site has and links to machine-readable versions of every page.

I figured this site should have one.

The llms.txt file

~/justademo
$ curl -s https://justademo.sh/llms.txt

# Just A Demo

> A tech blog exploring code, infrastructure, and terminal aesthetics.

## Pages

- [About](https://justademo.sh/about/index.md): ...
- [Hello World](https://justademo.sh/posts/hello-world/index.md): ...

## Sections

- [Posts](https://justademo.sh/posts/index.md)
  

There’s also /llms-full.txt with the full content of every page inline. One request, whole site.

Hugo does the heavy lifting

Hugo already compiles markdown to HTML. Getting it to also output markdown and plaintext is a config change. The Arancini theme defines three new output formats.

nvim
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[mediaTypes."text/markdown"]
suffixes = ["md"]

[outputFormats.Markdown]
mediaType = "text/markdown"
baseName = "index"
isPlainText = true
notAlternative = false

[outputFormats.LLMS]
mediaType = "text/plain"
baseName = "llms"
isPlainText = true
notAlternative = true

[outputFormats.LLMSFull]
mediaType = "text/plain"
baseName = "llms-full"
isPlainText = true
notAlternative = true
NORMAL config.toml
toml 5 blogs 14.52%

Wire them up in hugo.toml and you’re done.

nvim
1
2
3
4
[outputs]
home = ["html", "rss", "llms", "llmsfull"]
page = ["html", "navigator", "markdown"]
section = ["html", "navigator", "rss", "markdown"]
NORMAL hugo.toml
toml 5 blogs 14.52%

Every blog post gets an index.md alongside its index.html. The homepage gets llms.txt and llms-full.txt. All built at compile time. Zero runtime cost.

Every page has a markdown version

The HTML includes a <link> tag so agents can discover the markdown:

<link rel="alternate" type="text/markdown"
      href="https://justademo.sh/posts/llms-txt/index.md"
      title="Just A Demo">

Or fetch it directly.

~/justademo
$ curl -s https://justademo.sh/posts/hello-world/index.md | head -20

---

url: https://justademo.sh/posts/hello-world/
date: 2025-09-06
description: Welcome to a terminal-inspired blog...
tags: [meta, intro]

---

# Hello World

...

Raw content. No HTML to parse, no tags to strip.

Content negotiation on a static site

I wanted the URL to stay the same. An agent shouldn’t need to know about /index.md. It should request the page, say what format it wants, and get it. That’s HTTP content negotiation, and a Netlify edge function makes it work on a static site.

nvim
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import type { Config, Context } from "https://edge.netlify.com";

export default async (request: Request, context: Context) => {
const url = new URL(request.url);
const path = url.pathname;

// Skip requests for files with extensions (assets, feeds, etc.)
if (path.match(/\.\w+$/)) {
return;
}

const accept = request.headers.get("accept") || "";

// If client wants markdown, try to serve the .md version
if (accept.includes("text/markdown")) {
const mdPath = path.endsWith("/")
? `${path}index.md`
: `${path}/index.md`;

    try {
      const response = await context.rewrite(
        new URL(mdPath, request.url)
      );

      if (response.ok) {
        const headers = new Headers(response.headers);
        headers.set("content-type", "text/markdown; charset=utf-8");
        headers.set("vary", "Accept");
        return new Response(response.body, { status: 200, headers });
      }
    } catch {
      // No markdown version available, fall through
    }

}

// Serve the normal response with Vary header
const response = await context.next();
const headers = new Headers(response.headers);
headers.append("vary", "Accept");
return new Response(response.body, {
status: response.status,
headers,
});
};

export const config: Config = {
path: "/_",
excludedPath: ["/css/_", "/js/_", "/images/_", "/fonts/\*"],
};
NORMAL content-negotiation.ts
typescript 5 blogs 14.52%

The function checks the Accept header. If the client wants markdown, it rewrites to the pre-built .md file. Otherwise it passes through to HTML. Every response gets a Vary: Accept header so CDN caches store separate versions.

The netlify.toml sets content types and keeps search engines away from the markdown duplicates.

nvim
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Serve markdown files with correct content type

[[headers]]
for = "/\*.md"
[headers.values]
Content-Type = "text/markdown; charset=utf-8"
X-Robots-Tag = "noindex"

[[headers]]
for = "/llms.txt"
[headers.values]
Content-Type = "text/plain; charset=utf-8"

[[headers]]
for = "/llms-full.txt"
[headers.values]
Content-Type = "text/plain; charset=utf-8"

# Content negotiation edge function

[[edge_functions]]
function = "content-negotiation"
path = "/\*"
NORMAL netlify.toml
toml 5 blogs 14.52%

Proving it works

~/justademo
$ # Normal request gets HTML
$ curl -s -o /dev/null -w "%{content_type}" https://justademo.sh/posts/hello-world/
text/html; charset=utf-8

$ # Request with Accept: text/markdown gets markdown
$ curl -s -H "Accept: text/markdown" \
 -o /dev/null -w "%{content_type}" \
 https://justademo.sh/posts/hello-world/
text/markdown; charset=utf-8

$ # Vary header is set for CDN caching
$ curl -sI -H "Accept: text/markdown" \
 https://justademo.sh/posts/hello-world/ | grep -i vary
Vary: Accept

Same URL. Different response.

That’s it

A static site doing content negotiation. Hugo builds the formats at compile time. A Netlify edge function routes by Accept header. And llms.txt gives agents a table of contents.

All the code is in the repo. The theme templates are in Arancini. Fork it, copy and paste to solve your own problem.