AI-agent support
.md twin, the site root an llms.txt, and the reader a way to hand the current page to ChatGPT or Claude.An HTML page carries a sidebar, scripts and styles, and a model has to strip
that shell before reading it. OINK emits the same content a second time as plain
Markdown: one .md per page, one llms.txt index at the site root, and a “copy
as Markdown” button on the page. All three are build-time artifacts, with no
runtime service and no content negotiation.
All three have to be declared by the site under outputs; the theme does not
turn them on. Two further artifacts, equally opt-in, serve an agent that wants
more than one page at a time: a full-text bundle per section and a navigation
tree per language.
A .md per page
markdown is one of Hugo’s built-in output formats. Add it to the page kinds
that need it:
That is this site’s configuration. Each key under outputs is a wholesale
replacement rather than a merge: adding markdown means writing back every
format that kind already had (RSS, print), and omitting one loses that
output.
The URL rule is the page URL plus index.md:
| Page | Markdown |
|---|---|
/docs/customize/agents/ |
/docs/customize/agents/index.md |
/docs/customize/ (section index) |
/docs/customize/index.md |
/ (site home) |
/index.md |
Each HTML page’s <head> also carries a discovery link, so a crawler need not
guess the URL:
What the .md contains
It is not rendered HTML converted back to Markdown but the source you wrote: the front matter becomes an H1 and a blockquoted summary, and the body follows verbatim, with shortcodes expanded in place into their own Markdown forms.
Components in their native Markdown form (callouts, tables, field lists, image
attribute lines, code fences, data fences) keep their source in the .md, so
what the model reads is what you wrote. A section index additionally appends a
Section pages: list of child links after the body.
Shortcode forms each have a defined degradation: a
badge becomes emphasized text or a link, a
key becomes Ctrl + K,
tabs become a run of **Label** subsections, and
fields become an item list. Each component page’s
Output section states its own row.
Where the site has not enabled the LLMS output, that LLMS index: line does
not appear: the theme never points at a file it did not publish.
llms.txt
llms.txt is a plain-text manifest at the site root
telling a model what the site holds and where the machine-readable versions
are. Add the LLMS output format to the home page to generate it:
A multilingual site gets one per language: /llms.txt and
/zh/llms.txt. The content is a generated site index:
Where the three sections come from: Site index is this language’s home page
plus the site’s main menu (menus.main, linking the Markdown version where an
entry has one, and carrying description where present); Documentation index
is the docs section’s subsections and the level of pages beneath them, indented
by level, each row carrying that page’s description; Site locales is every
language in the site configuration. Menu entries pointing off-site (GitHub, an
issue tracker) are dropped: they belong to the navigation shell rather than to
this site’s content.
The way to improve llms.txt is through the main menu and each section index’s
description, not through this template.
Full-text bundle
One .md per page suits an agent that already knows which page it wants; an
agent that wants the whole manual has to crawl it page by page. The LLMSFULL
output collapses that into one file per top-level section: llms-full.txt,
holding every page of the section in reading order. It is new in OINK 0.8.0 and
stays off until a section asks for it.
The switch is the section index’s own front matter rather than the site configuration:
Front matter outputs replaces the site-level list for that page, so write back
the formats the section already had: omitting markdown or print here costs
the section index those outputs. Front matter is per language, so a bilingual
site repeats the line in _index.zh.md to get the Chinese bundle.
The result is one file per language at the section root — /docs/llms-full.txt
and /zh/docs/llms-full.txt. The order is the reading order the sidebar and the
pager present: the explicit data/docs_nav.json tree where a docs or book
section declares one, the weighted content tree otherwise. Pages held out of the
sidebar (toc_hide) stay out of the bundle too.
Each page is introduced by a separator carrying its source URL, and the body
that follows is byte-identical to that page’s own .md:
Source: points at the page’s Markdown output, falling back to its HTML URL
where the page publishes no .md.
Only a top-level section can carry a bundle. Listing LLMSFULL further down the
tree warns — “LLMSFULL output requires a top-level section” — and emits nothing,
so hugo server keeps working while a publishing build with --panicOnWarning
stops there.
Where at least one section has a bundle, llms.txt grows a ## Full-text bundles list of this language’s bundles: discovery stays in the file an agent
already fetches.
This site’s docs section has it enabled: https://oink.pgsty.com/docs/llms-full.txt is the entire English documentation in one fetch.
Navigation JSON
The sidebar is the site’s table of contents, and an agent that can read it plans
a route before fetching anything. The NAVJSON output publishes it as data:
navigation.json, one file per language at the language root. Like the bundle
it is new in OINK 0.8.0 and off by default; the site turns it on for the home
page:
That yields /navigation.json and /zh/navigation.json. The tree is the one
the sidebar and the pager already read — the explicit data/docs_nav.json tree
where a docs or book section declares one, the weighted content tree
everywhere else:
| Key | What it holds |
|---|---|
id |
The page’s path with the language prefix removed, so the same page carries the same id in every language |
url |
The absolute URL of this language’s HTML page |
markdown |
The absolute URL of the page’s .md, present only where the page publishes one |
title |
The navigation title (linkTitle, falling back to title) |
description |
The page’s description, where it has one |
kind |
home, section or page for real pages; external or link for placeholders |
children |
The ordered children, where the node has any |
Array order is the contract, and weight is never serialized: the ordering has
already been applied, and a consumer re-sorting the array would disagree with
the sidebar the array came from.
Placeholder rows keep the shape the sidebar gives them: a manual_link entry
becomes a node of kind external carrying the URL as authored, a
manual_link_relref entry becomes kind link with the reference resolved.
Neither has page identity, so neither carries an id or a markdown URL.
Sidebar dividers and pages Hugo never renders drop out, while their children
stay in place.
The contract is versioned: schemaVersion is 1, and the JSON Schema ships in
the theme repository as
schema/nav.v1.schema.json
— validate against it if you consume the file. Where the site publishes it,
llms.txt lists navigation.json for its own language in the site index.
This site has it enabled: https://oink.pgsty.com/navigation.json is a live instance of the tree.
Agent actions on the page
Four entries in the action menu at the right of the breadcrumb row relate to agents:
| Entry | What it does | When it appears |
|---|---|---|
| Copy as Markdown | Fetches this page’s .md into the clipboard (prefetched on hover, so a click has no perceptible wait) |
This page has a markdown output |
| View Markdown source | Opens the .md in a new tab |
This page has a markdown output |
| Open in ChatGPT | Jumps to ChatGPT with a prompt | assistant_links: true |
| Open in Claude | The same, to Claude | assistant_links: true |
The first two exist as soon as the markdown output is on. “Copy” is the left
half of the split button (the clipboard icon), and shows a brief tick on
success.
The last two are off by default and must be enabled explicitly:
Where the boundary lies once enabled: on a click, the runtime composes a prompt
using the full URL from the address bar (real domain, query string and anchor
included) — in English, “Please read the contents of
A page may narrow the site policy but not reverse it: front matter
page_context_menu: { assistant_links: false } turns the assistant links off
for that page, while writing true where the site has not enabled them has no
effect. To turn the whole menu off for a page, use page_context_menu: false —
see Page parameters.
Both assistant actions are also searchable in the command palette, from the same action manifest — see Command palette.
Opting a page out of .md output
Rewrite outputs in the page’s front matter. It is likewise a wholesale
replacement, so write only the formats you keep:
To keep RSS and drop only Markdown, list the rest:
Customizing the output
The theme renders Markdown output with layouts/all.md, generates llms.txt
with layouts/index.llms.txt, and owns the two opt-in formats in
layouts/list.llmsfull.txt and layouts/index.navjson.json. A site replaces
any of them wholesale by placing a file of the same name under its own
layouts/, but consider a narrower approach first:
- Per content type: a typed path such as
layouts/blog/single.mdorlayouts/docs/list.mdaffects only that kind of content, which is how the theme’s own print templates are specialized (layouts/blog/single.print.html). Check the template lookup order for your combination. - Per shortcode: a site’s own shortcode can have an output-format-specific template giving it a more machine-readable form in Markdown output.
- Per page: hand-writing the content of a few high-value pages costs less than changing a template.
The content of llms.txt follows the site’s structure, so before changing the
template, confirm the problem is not in the main menu or a description.
Replacing index.navjson.json also takes over the nav.v1 contract: whatever
you emit still has to satisfy schema/nav.v1.schema.json for a consumer that
validates.
Verify
With curl, against production or a local preview:
Then check four things:
- Any page’s HTML
<head>hasrel="alternate" type="text/markdown"; - Clicking the copy button at the right of the breadcrumb row and pasting yields Markdown rather than HTML;
llms.txtcontains no off-site links;- Where you enabled them: every page in
llms-full.txtopens with aSource:line, and the same page carries the sameidin each language’snavigation.json.
Limits
- The machine-readable surface the theme produces is four build-time files: a
.mdper page,llms.txt, and — where you opt in —llms-full.txtper top-level section andnavigation.jsonper language. The sitemap is still Hugo’s ownsitemap.xml. - A bundle belongs to a top-level section. There is no whole-site
llms-full.txt: an agent that wants everything reads one bundle per section, listed inllms.txt. LLMS,LLMSFULLandNAVJSONare all declared as non-alternative formats, so none of them appears in the<head>alternate links or gains a page action. They are discovered by their conventional paths and by the entriesllms.txtcarries for them.- Server-side content negotiation (one URL returning Markdown for
Accept: text/markdown) is outside the theme’s scope and belongs to the hosting layer. - Markdown output follows the source path: content generated only in the browser by JavaScript (a runtime-drawn chart) appears in the
.mdas fence source, not as a diagram.
Related
- Print — the other non-HTML output
- Command palette — the other entry point to the assistant actions
- Page parameters —
outputs/assistant_links/page_context_menu - Navigation and menus —
llms.txt’s site index comes from the main menu - Configuration — full definitions of
outputsandparams.ui.page_context_menu.*