Hugo llms.txt and Plain-Text Output for AI Agents on GitHub Pages

Page content

HTML is fine for humans in a browser. For AI agents it is often a pile of navigation, scripts, sidebars, and chrome wrapped around the few paragraphs that actually matter. If you run a technical Hugo site, you can serve a clean text sibling of every post plus a site-wide llms.txt index so agents skip the HTML tax.

Hugo

This post walks through the setup I use on DevOpsTales, based on the approaches from PhotoStructure and Ravikanth Chaganti. The important twist for GitHub Pages hosts: do not ship per-post index.md. Pages does not reliably serve those files. Use index.txt instead.

Why bother

When an LLM fetches a documentation page, it often has to dig through headers, menus, footers, and sometimes client-rendered content that never shows up in a simple HTTP GET. A plain-text (or Markdown-shaped) file next to the HTML page gives the model the article body immediately, with fewer tokens and less noise.

Hugo already knows how to emit multiple output formats from the same content. You do not need a plugin or a post-processing script. You define formats, point page kinds at them, and drop in two small templates.

Define output formats

In config.toml (or hugo.toml), define a per-page format and a home-page llms format. On this site the per-page format is still named markdown in Hugo, but the on-disk suffix is plain text:

[outputFormats.markdown]
  name        = "markdown"
  baseName    = "index"
  mediaType   = "text/plain"
  isPlainText = true

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

isPlainText = true makes Hugo use Go text/template instead of html/template, so angle brackets and quotes in your source are not HTML-escaped.

Assign the formats to page kinds:

[outputs]
  page = ["HTML", "markdown"]
  home = ["HTML", "RSS", "ATOM", "llms", "WebManifest"]

After a build you get:

  • public/llms.txt at the site root
  • public/<section>/<slug>/index.txt next to each post’s index.html

Per-page template

Hugo picks the template from the output format. For the markdown format with a .txt suffix (via text/plain), create layouts/_default/single.txt:

---
title: {{ .Title }}
url: {{ .Permalink }}
{{- with .Description }}
description: {{ . }}
{{- end }}
{{- with .Date }}
date: {{ .Format "2006-01-02" }}
{{- end }}
{{- with .Params.keywords }}
keywords: {{ delimit . ", " }}
{{- end }}
---

{{ .RawContent }}

.RawContent is the original Markdown source. Hugo does not render it to HTML, so headings, fences, and links stay intact for the agent.

PhotoStructure and Ravikanth both use single.md and text/markdown. That is the cleaner MIME story if your host will serve .md. On GitHub Pages it is the wrong choice for the public URL. Keep the template content the same; only the filename and media type change.

Site index: llms.txt

Create layouts/_default/index.llms.txt:

# {{ .Site.Title }}

> {{ .Site.Params.description }}

{{ .Site.BaseURL }}

## Documentation
{{ range .Site.RegularPages }}
- [{{ .Title }}]({{ .Permalink }}index.txt): {{ with .Description }}{{ . | htmlUnescape }}{{ else }}{{ .Summary | plainify | htmlUnescape | truncate 120 }}{{ end }}
{{- end }}

Each list entry should point at the text sibling (index.txt), not the HTML page. That is the whole point of the index: agents discover clean bodies in one hop from /llms.txt.

Pretty URLs do not make https://example.com/post.md work. Add an alternate link in the document head so clients that understand rel="alternate" can find the text version. In layouts/_default/baseof.html:

{{- with .OutputFormats.Get "markdown" }}
<link href="{{ .Permalink }}" rel="alternate" type="text/plain" title="{{ $.Title }}" />
{{- end }}

Use type="text/plain" when the sibling is .txt. If you ever serve real Markdown on a host that allows it, switch back to type="text/markdown".

The GitHub Pages gotcha

The guides above generate index.md per post. That works on many static hosts. On GitHub Pages I found that those .md files are not served the way you expect: the HTML page is fine, /llms.txt is fine, but fetching /some-post/index.md fails or is blocked for agent and browser clients.

The fix that worked here:

  1. Keep the Hugo output format name as markdown if you want.
  2. Set mediaType = "text/plain" so the built file is index.txt.
  3. Rename the template from single.md to single.txt.
  4. Point llms.txt links at index.txt.
  5. Advertise type="text/plain" in the alternate link.
  "works on many hosts"          "works on GitHub Pages"
  /post/index.html  <------+----->  /post/index.html
  /post/index.md    <------+       /post/index.txt
  /llms.txt         <------+----->  /llms.txt

Same content, different extension. Agents do not care that the file ends in .txt as long as the body is clean Markdown-shaped text.

Verify

Rebuild and check the artifacts:

hugo

# Site index
curl -s https://devopstales.github.io/llms.txt | head

# Per-post text sibling (use a real slug from your site)
curl -sI https://devopstales.github.io/hugo/hugo-llms-txt-and-plaintext-output/index.txt

# Alternate link in HTML
curl -s https://devopstales.github.io/hugo/hugo-llms-txt-and-plaintext-output/ | grep 'rel="alternate"'

Locally, hugo server and open a post: the head should include the alternate link, and /llms.txt should list pages with index.txt URLs.

Summary

Custom Hugo output formats are enough to ship an agent-friendly surface: /llms.txt as the map, and a plain-text sibling for every article. The published recipes from PhotoStructure and Ravikanth Chaganti get you most of the way. If you host on GitHub Pages, swap index.md for index.txt so the files are actually reachable after deploy.