Guide
Markdown for agents & AI search
SSG is Markdown-native, so it can hand language models and AI crawlers the authored Markdown of every page instead of making them parse rendered HTML. This guide covers the flags that do it and how they map to Google and ChatGPT.
markdown_publish
1markdown_publish: true
With this on, every page is published a second time as clean Markdown:
/<page>/index.md— next toindex.html, sohttps://site/guide/index.mdserves the Markdown./<page>.md— the flat sibling, for agents that append.mdto a clean URL (https://site/guide.md).- A
<head>discovery link on every page:<link rel="alternate" type="text/markdown" href="index.md">. - A root
llms.txtindex listing every page and pointing at its Markdown copy (the llms.txt convention).
The published copy is the Markdown you wrote — an H1 title followed by the body — not an HTML→Markdown round-trip, so nothing is lost or re-guessed. Listing pages (the home page, archives) carry no source Markdown and are skipped.
webmcp
1webmcp: true
markdown_publish hands an agent the site's text. WebMCP hands it the site's
tools: the built pages declare callable functions through the browser's
navigator.modelContext API, so
an agent that opens the site can ask it questions instead of scraping the DOM.
This is not ssg mcp. The two are easy to confuse and share nothing but a
name:
ssg mcp |
webmcp |
|
|---|---|---|
| Runs | on the author's machine | in the visitor's browser |
| Serves | the author's assistant | the visitor's agent |
| Can | write files, run git | read what the site already publishes |
| Lives | while you are writing | on the deployed site, forever |
The tools
Four, registered on every page the build writes — posts, pages, archives,
taxonomy listings, the home page and 404.html:
| Tool | Answers |
|---|---|
searchPosts(query, limit) |
Matching titles, URLs and excerpts, ranked |
listByTag(tag) |
Every document carrying that tag |
getDocument(url) |
One document's title, excerpt, tags, language and text |
navigate(url) |
Opens one of this site's own documents |
navigate refuses any URL not in the site's own index. An agent can move a
reader around the site it is already on and nowhere else.
What it costs a visitor without an agent
Nothing measurable. The script is a few kilobytes inline, and it does two
things: check whether navigator.modelContext exists, and stop if it does not.
The search index is fetched on the first tool call, never at page load — a
reader who has no agent never requests it.
It brings its own data
The tools answer from search-index.json, so webmcp: true turns
search_index on. Four tools that all throw on first call is worse than no
tools, and that is what shipping the script alone would produce. With i18n
enabled each language gets its own index and each page reads the one in its own
language.
Draft status, and what follows from it
WebMCP is a W3C Community Group Draft, not a Recommendation. Chrome
implements navigator.modelContext; other engines are engaged in the spec work
without shipped support. So:
- The feature is off unless you ask for it, and a site that does not ask is unchanged byte for byte.
- The script feature-detects and exits where the API is absent, which is most visits today. It registers nothing, fetches nothing and touches no DOM.
- A theme that ships its own registration keeps it — SSG detects one and leaves the document alone rather than registering a second set.
If the API changes shape, the switch is what keeps that from becoming your problem: turn it off and the site is what it was.
components.json
A site with typed content components publishes its contract at the root:
1{
2 "schema": 1,
3 "components": [
4 { "name": "youtube",
5 "description": "Embed a video from a privacy-friendly host.",
6 "example": "{{< youtube id=\"…\" >}}",
7 "props": [
8 { "name": "id", "type": "string", "required": true, "description": "The video id." },
9 { "name": "ratio", "type": "string", "default": "16x9", "enum": ["16x9", "4x3"] }
10 ] }
11 ]
12}
This is the difference between an agent that can write content for a site and one that can only write prose. Without it, a model generating a page either avoids the site's own components or invents attributes for them, and nobody notices until a reader does. With it, the contract is explicit — the types, what is required, what values an enum allows — and a call that gets it wrong is caught by the build rather than by a proofreader.
The example is the shortest call that would validate: something to copy, not a
grammar to infer. Read it before generating a call; see
COMPONENTS for the whole feature.
site_graph
1site_graph: true
markdown_publish hands an agent the site's text. site_graph hands it the
site's shape: one JSON document describing every page, section, taxonomy,
link and redirect the build produced, stamped with the build that produced it.
1{
2 "schema": 1,
3 "build": { "version": "1.8.60", "time": "2026-09-10T12:00:00Z", "hash": "3f9c…" },
4 "domain": "example.com",
5 "pages": [ { "url": "/blog/hello/", "type": "post", "title": "Hello", "canonical": "https://example.com/blog/hello/",
6 "components": ["youtube"], "relations": [{ "name": "see_also", "url": "/about/" }],
7 "tags": ["go"], "categories": ["News"], "translations": [{ "lang": "pl", "url": "/pl/blog/czesc/" }],
8 "outputs": { "html": "/blog/hello/", "markdown": "https://example.com/blog/hello/index.md" } } ],
9 "sections": [ { "path": "/tag/go/", "kind": "tag", "title": "go" } ],
10 "taxonomies": [ { "name": "tag", "path": "tag", "terms": [ { "name": "go", "slug": "go", "url": "/tag/go/", "count": 4 } ] } ],
11 "links": [ { "from": "/blog/hello/", "to": "/about/", "kind": "page" } ],
12 "redirects": [ { "from": "/old", "to": "/blog/hello/", "status": 301 } ]
13}
Why it exists: an agent working on a site otherwise learns what the site contains by listing directories and reading files, re-deriving what the build already knew. The build computes every one of these facts — the link checker alone parses every output page and extracts every reference, then discards the result once it has validated it. The graph keeps them.
One model, several views. routes.json and llms.txt are now generated
from the same in-memory graph, whether or not site_graph is on — so the
manifests cannot drift from each other, and their bytes did not change when
the source of truth moved (the golden corpora are the proof). search-index.json
stays a text index; its metadata agrees with the graph, its text is its own.
What is in it, and what is not. Everything in the artifact is something the
published HTML already reveals. What a page was rendered from — its source
file, its template — is the project's structure rather than the site's content,
and is not published here (it still appears in routes.json, which has always
carried it).
build is how a reader tells fresh from stale. hash covers the content,
not the clock: the same site built twice hashes equal, a changed site does not.
Scale. Above 10,000 pages the file becomes an index and the page and link
records move to JSON Lines under site-graph/, so a consumer can stream one
shard rather than load fifty thousand records to find one.
Asking the graph over MCP
ssg mcp gains a site section when it knows the output directory — six
read-only tools that answer from the last build's graph and name the build
they answer from, plus one that answers from the dependency graph:
| Tool | Answers |
|---|---|
site_pages |
every page, filterable by type and lang, paged with limit/offset |
site_page |
one page in full, with every link out of it and into it |
site_links |
the link graph, filterable by from, to and kind (page, asset, external) |
site_taxonomies |
every taxonomy with its terms, archive URLs and counts |
site_redirects |
every rule the host will apply |
site_components |
which pages use which component — ask before changing one |
site_dependencies |
what a page was built from, or what editing a file rebuilds |
"Which pages link to /pricing/?" is one call rather than a grep across the
output. "What breaks if I change this partial?" is site_dependencies, which
reads .ssg-cache/graph/ rather than the published artifact: what rendered a
page is the shape of the project, not of the site, so it is not published. The tools are read-only by design: the graph is a model of the site,
not a CMS, and mutation stays with the file-shaped tools that know how.
clean_special_chars
1clean_special_chars: true
AI tools routinely emit "smart" Unicode — curly quotes, en/em dashes, ellipsis characters, non-breaking and zero-width spaces. This normalises them to plain ASCII across all rendered content (HTML, the published Markdown, feeds and the search index).
It targets a fixed Western-punctuation allowlist only. Chinese, Japanese and Korean text — and CJK's own full-width punctuation (、。()) — pass through untouched, as does every other script. Off by default, because many themes use this typography deliberately; enable it where the content is known to carry AI artefacts.
output_encoding
1output_encoding: utf-8 # utf-8 (default) | utf-16le | utf-16be
2output_encoding_sections: # optional per-section overrides
3 legacy: utf-16le
Selects the encoding of the text output (HTML pages, published Markdown,
llms.txt). UTF-16 output carries a byte-order mark and the HTML <meta charset>
is kept in step. Overrides are keyed by content section using the same
longest-prefix rule as schema_defaults (home is the site root).
Every option is Unicode, so Chinese/Japanese/Korean and all other scripts round-trip losslessly in UTF-8 and UTF-16 alike. Sitemaps, feeds and JSON stay UTF-8 — their formats standardise on it or carry their own encoding declaration.
robots_rules
1robots_rules:
2 - { user_agent: "*", allow: ["/"] }
3 - { user_agent: GPTBot, allow: ["/"] }
4 - { user_agent: OAI-SearchBot, allow: ["/"] }
5 - { user_agent: Google-Extended, allow: ["/"] }
Replaces the default permissive robots.txt (User-agent: * / Allow: /) with
explicit per-crawler directives, so you can state your policy for AI and search
crawlers. The Sitemap: line is always appended. Empty keeps the allow-all
default — SSG never blocks a crawler unless you ask it to.
Compatibility: Google vs ChatGPT
The two treat this differently, and the guidance is not the same:
| Google Search (AI Overviews / AI Mode) | ChatGPT Search & other LLMs | |
|---|---|---|
Reads llms.txt / Markdown alternates |
No — ignored; reads standard HTML | Yes — consumes Markdown directly |
| What earns eligibility | Standard indexing + snippet eligibility | Crawlable content it can fetch and read |
| Structured data | Helpful for rich results, not an AI lever | Helps disambiguation |
For Google, there is no special "AI SEO": ship solid, crawlable, standard
HTML. SSG already covers that surface — seo (OpenGraph/Twitter/JSON-LD),
schema/schema_defaults, canonical tags, sitemap, check_meta,
check_images, check_orphans, hreflang/i18n, lastmod_from_git. llms.txt
and Markdown alternates do not affect Google ranking (it ignores them).
For ChatGPT Search and other Markdown-reading agents, markdown_publish
(with clean_special_chars) is exactly what helps: they get the clean authored
Markdown and the llms.txt index. Keep robots_rules from blocking GPTBot /
OAI-SearchBot (the default allow-all already doesn't).
In short: standard SEO flags for Google, markdown_publish for the LLMs, and
robots_rules to state the policy for both.