---
title: "How to serve text/markdown to AI agents on Hugo"
slug: markdown-for-ai-agents-hugo
published: 2026-10-03T13:20:59.176915+00:00
updated: 2026-10-04T12:06:14.833327+00:00
author: "AIScan Content Team"
author_url: https://aiscan.site/about
category: "AI Readiness"
tags: check:C1, platform:hugo, Hugo, Markdown, content negotiation, nginx, AI agents
description: "Make Hugo return Markdown to AI agents that send Accept: text/markdown with one output format, one template and one nginx rule, tested on Hugo v0.167.0."
url: https://aiscan.site/blog/markdown-for-ai-agents-hugo
---

An AI agent that sends `Accept: text/markdown` to a Hugo page gets HTML back, because Hugo builds files and a file cannot read a request header. AIScan check C1 grades exactly that request. This guide makes Hugo write a Markdown twin of every page at build time, then uses one nginx rule to hand the twin to agents at the same URL. Everything below was built and run on Hugo v0.167.0 and nginx 1.24.0 on 3 October 2026. Browsers keep getting HTML.

## Quick summary

| If you want to... | Do this | Time |
|---|---|---|
| Write a Markdown file next to every HTML page | Add the built-in `markdown` output format to `[outputs]` | 2 min |
| Make that file readable by agents | One `layouts/page.md` template using `transform.HTMLToMarkdown` | 5 min |
| Serve it at the original URL | An nginx `map` on the `Accept` header plus `try_files` | 10 min |
| Confirm it works | `npx aiscan-cli yoursite.com`, then read the C1 row | 1 min |

## Why Hugo needs two halves

Check C1 re-requests a page with `Accept: text/markdown` and passes when the server answers `text/markdown`. That is proactive negotiation as defined in [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#name-proactive-negotiation): one URL, several representations, chosen by a request header. A `/page.md` sidecar alone does not pass, because the scanner asks the original URL.

Hugo can do the first half and cannot do the second. The [output formats documentation](https://gohugo.io/configuration/output-formats/) lists `markdown` among the built-in formats, with media type `text/markdown` and the base name `index`. Hugo has no request-time code, so the choice between the two files belongs to whatever serves them. The dev server confirmed it: `hugo server` answered `Accept: text/markdown` with `text/html; charset=utf-8`.

## Step 1: turn on the Markdown output format

Open **`hugo.toml`** and list `markdown` next to `html` for the page kinds you want agents to read. The home page and section lists need their own entries, otherwise they have no Markdown twin.

```toml
[outputs]
  home    = ['html', 'markdown']
  section = ['html', 'markdown']
  page    = ['html', 'markdown']
```

Hugo now writes `public/posts/hello/index.md` beside `public/posts/hello/index.html`. Change nothing else yet; the file will be empty until a template exists.

## Step 2: add the Markdown templates

Hugo 0.146 overhauled template lookup, and the [lookup order page](https://gohugo.io/templates/lookup-order/) now names templates by kind, so a single page is `page.md`, the home page is `home.md` and a section is `section.md`. Create **`layouts/page.md`**:

```go-html-template
# {{ .Title }}

{{ .Content | transform.HTMLToMarkdown }}
```

Then create **`layouts/home.md`** and copy it to **`layouts/section.md`**:

```go-html-template
# {{ .Site.Title }}

{{ range site.RegularPages }}- [{{ .Title }}]({{ .Permalink }})
{{ end }}
```

I used `transform.HTMLToMarkdown` instead of `.RawContent` on purpose. `.RawContent` prints the source file, so a page containing `{{< figure src="/a.png" caption="Cap" >}}` came out with the shortcode text still in it. Converting the rendered `.Content` produced `![Cap](/a.png)` and a caption line instead, and tables survived as pipe tables. The [function's documentation](https://gohugo.io/functions/transform/htmltomarkdown/) marks it as new in v0.151.0 and experimental, so pin your Hugo version and re-run the check after upgrades.

## Step 3: advertise the twin in the HTML head

Agents that read the page first can find the twin from a link element. Add this inside `<head>` in **`layouts/baseof.html`**, or in the partial your theme uses for the head:

```go-html-template
{{ range .AlternativeOutputFormats -}}
  <link rel="{{ .Rel }}" type="{{ .MediaType.Type }}" href="{{ .Permalink }}">
{{ end -}}
```

The built page carried `<link rel="alternate" type="text/markdown" href="https://example.com/posts/hello/index.md">`. This is a hint and does not replace Step 4.

## Step 4: route by Accept header in nginx

Hugo's output is a folder of files, so the rule is "serve `index.md` instead of `index.html` when the client prefers Markdown". In the `http` block of **`/etc/nginx/nginx.conf`** or a file it includes, add:

```nginx
types { text/markdown md; }

map $http_accept $index_file {
    default index.html;
    "~*^text/markdown(?!\s*;\s*q=0(\.0+)?(,|$))" index.md;
}
```

Then, in your **`server`** block, point `root` at the Hugo `public` folder and use `try_files`:

```nginx
add_header Vary Accept always;

location / {
    try_files $uri $uri$index_file $uri/$index_file =404;
}
```

Reload with **`nginx -t && nginx -s reload`**. The pattern matches only when the header begins with `text/markdown` and that type is not set to `q=0`. A browser header that starts with `text/html` never matches. `Vary: Accept` tells caches that the same URL has two answers.

If you would rather use Caddy, Apache, or a CDN rule, the logic is the same, but I tested only nginx.

## Step 5: send one URL several Accept values

Request the same page with different headers and read the status and content type. These are the results from the test site:

| Accept header | Content-Type returned |
|---|---|
| none | `text/html` |
| `text/markdown` | `text/markdown` |
| `text/markdown, text/html;q=0.8` | `text/markdown` |
| `text/html, text/markdown;q=0.5` | `text/html` |
| `*/*` | `text/html` |
| `text/markdown;q=0` | `text/html` |
| `text/markdown;q=0, text/html` | `text/html` |

All of them returned 200, including `/posts/hello` without the trailing slash, and every response carried `Vary: Accept`. The home page also answered Markdown once `home` was listed in `[outputs]`. Before I added it, `Accept: text/markdown` on `/` returned 404, because `index.md` did not exist at the root.

## Two traps I hit

The first is the MIME type. Without the `types { text/markdown md; }` line, nginx served `index.md` as `text/plain`, and C1 wants `text/markdown`. The stock `mime.types` file on Ubuntu has no entry for `md`.

The second is the host. A Hugo site on GitHub Pages, or any host that only serves files by path, cannot read the header, so Steps 1 to 3 still help (the `.md` files exist and are linked) but C1 will not pass. I did not test Netlify or Cloudflare Pages here; check that host's documentation for header-based rewrites, then confirm with the commands below.

## Check it: scan first, curl second

Run the scanner against the live site and read the C1 row:

```bash
npx aiscan-cli yoursite.com
```

By hand, request one URL twice and compare:

```bash
curl -sI -H "Accept: text/markdown" https://yoursite.com/posts/hello/
curl -sI -H "Accept: text/html" https://yoursite.com/posts/hello/
```

A pass is `content-type: text/markdown` on the first, `content-type: text/html` on the second, and `vary: accept` on both. If the first still says HTML, the server is not reading the header; start at Step 4.

## What this does not cover

The scan makes one request from one place. It cannot see a CDN in front of nginx caching the wrong variant, so purge the cache after the change and confirm `Vary: Accept` survives it. It also cannot judge the Markdown itself. Open one converted page and read it as an agent would, especially pages with shortcodes, because the converter is experimental. Re-run the scan after each Hugo upgrade.

## Next step

Run `npx aiscan-cli yoursite.com` and read C1. The same check on other stacks is in [serving text/markdown on Astro](https://aiscan.site/blog/markdown-for-ai-agents-astro) and [on Next.js](https://aiscan.site/blog/markdown-for-ai-agents-nextjs). Hugo's other readiness files are in [publishing llms.txt on Hugo](https://aiscan.site/blog/llms-txt-hugo) and the [full Hugo setup guide](https://aiscan.site/blog/ai-readiness-setup-hugo). The check page is [/docs/checks/content](https://aiscan.site/docs/checks/content) and every other fix is in [/guides](https://aiscan.site/guides).

