Skip to main content

Findable by crawlers, readable by agents.

ApiGlow renders at runtime, in the browser, under hash routes — and that is exactly what the crawlers that matter cannot read. AI crawlers (GPTBot, ClaudeBot, PerplexityBot) fetch HTML but run no JavaScript; Google runs JavaScript but treats every #/… route as the same URL, so it can index the home view and never an operation page. The answer has two halves: what every install already does at runtime, and what the apiglow bake CLI writes to disk.

What every install already does

The app manages the document head on every route change, with zero configuration:

  • <title> — “{route} — {API title}”: the operation summary (or GET /path), the docs page title, the workflow name. Once booted, the app owns the title.
  • Meta description — created if the host page has none, updated in place otherwise: plain text derived from the route’s own content, capped around 160 characters.
  • JSON-LD — one block per route: APIReference for endpoints, TechArticle for docs pages and scenarios, WebSite on the home view. Views no type fits (the audit, the first-call page) emit none — a wrong type is worse than silence.

There is deliberately no runtime canonical: under hash routing every route shares one server URL, and fragments are stripped by crawlers — a per-route canonical would be meaningless. Canonicals live in the baked snapshots.

Staying out of the index

Some documentation must not be found. One key does it:

api-doc-config
{ "seo": { "index": false } }

The app injects <meta name="robots" content="noindex"> before first paint. The key is root-only in multi-spec — one page is served at one URL, and a crawler reads that HTML without ever choosing a spec. Two deployment notes from the config’s own comments: pair it with an X-Robots-Tag: noindex header to cover non-HTML files (the schema itself, baked .md mirrors), and do not also Disallow the page in robots.txt — a blocked URL is never fetched, so the noindex is never seen. And it is a request to well-behaved crawlers, not a protection: documentation that must not be read needs an auth wall, not a meta tag.

The bake: a static mirror from one command

apiglow bake is a companion CLI, run by the docs author — never by the reader. It ships inside the npm package ("bin": { "apiglow": "dist/bake.js" }, Node ≥ 24) and reads the same JSON config your host page inlines:

apiglow bake --config apidoc.config.json --site-url https://docs.example.com/ --out public/
FlagMeaning
--configthe JSON config the host page inlines in #api-doc-config
--site-urlabsolute URL of the deployed documentation page
--outdirectory the static tree is written to
--languagecatalog used for the snapshots’ chrome — en or fr, default en

It writes one file tree, deployed next to the app page by whatever already deploys your site:

out/
sitemap.xml
llms.txt
llms-full.txt
overview.html
op/{operationId}.html        op/{operationId}.md
page/{slug}.html             page/{slug}.md
scenario/{scenarioId}.html   scenario/{scenarioId}.md
scenario/{scenarioId}.arazzo.json

Every route gets an HTML snapshot — canonical to itself, a <link rel="alternate" type="text/markdown"> to its sibling, JSON-LD, zero scripts, and one prominent link back into the interactive app (“Open in the interactive documentation”). No redirect, no hydration: the snapshot is honest static content, not a cloaking trampoline. Beside it sits a Markdown mirror — the same generator the in-app “Copy page” uses, {{var}} left literal. Each scenario additionally publishes its Arazzo recipe, the file a CI runner executes unchanged. overview.html is the crawler’s entry point: a link to every snapshot, so landing on the site never means a sitemap and nothing else. In multi-spec, everything nests under s/{specId}/… and the three root files cover all specs.

This is not a prerender and not static-site generation — no DOM runs in Node, and the reader’s install stays one script tag with no build step. It is the app’s export layer, written to disk: if a snapshot and the app ever disagree, the generator is what gets fixed.

What agents get out of it

Unbaked, llms.txt and llms-full.txt exist only as in-browser downloads whose links are hash routes — nothing an agent can fetch. Baked, both are served files: llms.txt links the .md mirrors per the llmstxt.org convention, and each llms-full.txt section carries a Source: line pointing at its served page. The agent surface guide covers what is inside the files themselves.

CI-friendly by design

The bake resolves its two kinds of addresses differently, on purpose: files the config names are read from disk relative to the config file (leading / included — only a URL with a scheme is fetched), while every URL written into a generated file resolves against --site-url. That is what lets the bake run in CI before anything is deployed.

What the bake refuses, and what it degrades

  • A config saying seo: { index: false } is a hard error — baking a noindex site is a contradiction.
  • A schema that will not load ends the run; everything else derives from it.
  • A docs page carried by contentId lives in host-page HTML no Node process sees: it is named in the warnings and dropped from the map, the sitemap and the tree — an entry pointing at a file nobody wrote is worse than one entry fewer.
  • Raw HTML inside Markdown is escaped, not rendered in snapshots (no DOM sanitizer in Node): a baked page can render less richly than the app. Documented fallback, not a bug.
  • A scenario whose schema is inlined (openapi.spec) gets no Arazzo recipe — a generated recipe would name a source no runner can fetch. Warned, not silent.
  • The reader’s user overlay is never baked — what lives in a reader’s browser stays there.
  • The sitemap carries no <lastmod> (the generator has no clock, and a bake date would lie about when the documentation changed), and the bake emits no robots.txt and no index.html — those stay the site’s own.

Re-run the bake whenever the schema, the config, the prose pages or the scenarios change — in practice: from the same CI job that deploys the site.