AI & agents
Every page is machine-readable for free — a Markdown twin, an llms.txt index, and a read-only MCP endpoint, all built from the same render your readers see.
Machine-readable, for free#
Four surfaces, one render — the .md twin, llms.txt, llms-full.txt, and MCP all read from the page you already wrote.
docs-kit derives its agent-facing surfaces from the same render the HTML pages use — so they never drift and you author nothing extra:
- The
.mdtwin — every page has a GFM Markdown copy atGET /docs/x.md, converted post-render from the page's own HTML. This page's twin is /docs/ai.md. - The "Markdown" action — the masthead button that copies (or opens) the current page's twin.
- /llms.txt + /llms-full.txt — the llmstxt.org index and the full-text concatenation, built straight from the registry.
- The MCP endpoint — an optional read-only
POST /mcpserver exposinglist_pages/get_page/search_docs.
None of it is a second source of truth. The .md twin is a conversion of the rendered #docs-content; llms.txt is the registry; MCP reads the same twins and the same search index.
The .md twin#
GET /docs/x.md returns faithful Markdown of exactly what /docs/x shows.
A controller that includes DocsKit::Controller gets the twin automatically: render_page(view) serves the page's GFM Markdown instead of HTML when the request format is .md (or .text as an alias). Same page class, same render, text/markdown body — you write nothing extra.
class DocsController < ApplicationController
include DocsKit::Controller
def show
render_page(Views::Docs::Pages.const_get(params[:page].classify).new)
end
endThe conversion is DocsKit::MarkdownExport: it renders the page to HTML, extracts the #docs-content subtree that DocsUI::Shell stamps, strips [data-md-skip] / <script> / <style>, and walks the remaining DOM to GFM. Because it runs after the render, authoring style is irrelevant — Phlex components, md islands, and raw tags in prose all convert identically. A page with no #docs-content region yields an empty body (200 with ""), never a 404, and the HTML route is untouched.
.md/.text format (a format-aware or catch-all route). .text is accepted only so hosts whose routes permit the built-in :text format still get the twin.The page chrome never leaks into the twin: DocsUI::Page stamps data-md-skip on its top nav (the "← Home" link and the "Markdown" action), so the export drops it. Anything you want kept out of the .md can opt out the same way — wrap it in data: { md_skip: true }.
| Surface | Kind | Default | Description |
|---|---|---|---|
GET /docs/x.md | route | — | The page's GFM twin (.text is an alias). |
DocsKit::Controller#render_page | method | — | Serves the twin on a .md/.text request, HTML otherwise. |
DocsKit::MarkdownExport.new(view).to_md | class | — | The HTML→GFM converter over #docs-content. |
data: { md_skip: true } | attribute | — | Opt any wrapper out of the exported Markdown. |
The "Markdown" masthead action#
The button at the top of this page — copy the page as Markdown, or open the raw twin with JS off.
DocsUI::MarkdownAction renders the small "Markdown" button in the masthead (a clipboard icon + label), pointing at the current page's .md twin. DocsUI::Page renders it automatically when DocsKit.configuration.page_markdown_action is true (the default) — look at the top-right of this page.
With JS off it simply opens the raw Markdown — a working fallback, never a dead end. With JS on, the one docs-nav Stimulus controller upgrades the click: it fetches the same .md URL with Accept: text/markdown, writes the body to the clipboard, and flashes the label to "Copied!" for 1500ms. Any failure (no clipboard API on an insecure context, a non-ok fetch) falls back to normal navigation to the raw .md.
# Rendered for you by DocsUI::Page; the href is request.path + ".md":
render DocsUI::MarkdownAction.new(request.path)The href is built from the request path and is idempotent about the query string: /x → /x.md, /x?q=1 → /x.md?q=1, and an existing /x.md is left as-is. The affordance lives inside the page's data-md-skip nav, so it never appears in the exported twin.
c.page_markdown_action = false — the .md route itself keeps serving the twin regardless; the knob only controls the UI.| Surface | Type | Default | Description |
|---|---|---|---|
DocsUI::MarkdownAction.new(path) | String | request.path | The page whose .md twin the button targets. |
docs-nav#copyMarkdown | action | — | Fetches + copies the twin; falls back to opening it. |
c.page_markdown_action | Boolean | true | Show the masthead button (the .md route ignores this). |
/llms.txt and /llms-full.txt#
The llmstxt.org index and full-text dump — built straight from the registry, zero authoring.
docs-kit's engine ships no routes — it is glue-only, so a site keeps full control over path, auth, and omission. The install generator draws the two llms routes for you:
get "/llms.txt" => "docs_kit/llms#index", as: :llms
get "/llms-full.txt" => "docs_kit/llms#full", as: :llms_full/llms.txt is the llmstxt.org index, built by DocsKit::LlmsText.index:
- an
# {brand}H1 (c.brand, default"Docs"— always present), - a
> {tagline}blockquote (c.tagline) right under it — this site sets it to the shell's one-line summary; nil or empty omits the line, - one
## {group}section per nav group, a tight bullet list of each authored page's absolute.mdlink, in registry order, - a trailing
## MCPblock only when the MCP endpoint is live.
/llms-full.txt concatenates every authored page's Markdown twin — each as # {title} + its rendered Markdown, separated by a --- rule.
Both include only pages with a resolvable view_class — an unwritten registry entry is excluded from the links and the concatenation, so neither ever references a page that doesn't exist yet. Links are absolutized against request.base_url, so agent tooling fetches a portable URL.
DocsKit::VERSION as the etag salt, so any registry, config, or page change busts the cache while an unchanged site serves a 304 Not Modified.| Surface | Type | Default | Description |
|---|---|---|---|
c.brand | String | "Docs" | The # H1 of the index (always emitted). |
c.tagline | String, nil | nil | The > blockquote under the H1; nil/empty omits it. |
c.nav_registries | Hash | {} | Registries → the ## group sections and their .md links. |
DocsKit::LlmsText.index / .full | class | — | The pure builders the LlmsController threads request.base_url into. |
The read-only MCP server#
POST /mcp — list_pages / get_page / search_docs over JSON-RPC, gated on the optional mcp gem.
docs-kit ships a built-in read-only MCP server so an agent can connect over the protocol instead of scraping. It exposes three tools over POST /mcp (JSON-RPC), all reading the same registry, .md twins, and search index the docs render from:
list_pages— every authored page as{slug, title, group, url},get_page— one page's GFM twin by slug (unknown slugs return the list of valid ones, so an agent self-corrects),search_docs— ranked full-text search returning{page_title, section_title, url, snippet}.
mcp gem is loadable AND c.mcp is true (the default). Off in either case → the controller 404s and the site is byte-identical to before the feature. A fresh site has c.mcp = true but no live endpoint until you add the gem and uncomment the routes.Enabling it is two steps. Add the gem:
gem "mcp" # optional — powers the built-in POST /mcp server…then uncomment the routes the install generator drew for you (commented out, because the gem is optional). POST speaks JSON-RPC; GET/DELETE return 405 — the server is stateless and read-only, so there is no SSE session to open or terminate:
match "/mcp" => "docs_kit/mcp#method_not_allowed", via: %i[get delete]
post "/mcp" => "docs_kit/mcp#create"The controller delegates the whole protocol to the official MCP SDK: DocsKit::McpServer.build constructs the MCP::Server (named from c.brand, versioned from DocsKit::VERSION) and registers the three tools; server.handle_json(request.body.read) parses, dispatches, and serializes the JSON-RPC response. All three tools' logic lives in DocsKit::McpTools as pure plain-Ruby functions with zero gem and zero JSON-RPC dependency — so the whole consumption story is unit-testable without booting Rails or the SDK.
Once live, /llms.txt grows its trailing ## MCP block advertising the endpoint, so an agent reading the index discovers it can also connect over the protocol.
| Surface | Type | Default | Description |
|---|---|---|---|
c.mcp | Boolean | true | Toggle; the endpoint needs this AND the mcp gem present. |
c.mcp_enabled? | method | — | !!c.mcp && the gem loadable — the gate the controller + llms.txt read. |
POST /mcp | route | — | JSON-RPC; GET/DELETE 405 (stateless, read-only). |
list_pages / get_page / search_docs | tools | — | The three read-only tools, over DocsKit::McpTools. |
AGENTS.md + the write-docs-page skill#
The install generator scaffolds an authoring contract every agent can read.
So an agent (or a teammate) can author pages the docs-kit way, the install generator scaffolds two files into the consuming site:
AGENTS.mdat the repo root — the cross-tool authoring contract. The generator owns a delimited block inside it (between<!-- BEGIN docs-kit -->/<!-- END docs-kit -->), so a re-run updates only that block and leaves the rest of yourAGENTS.mdalone. A fresh site gets the whole file..claude/skills/write-docs-page/SKILL.md— a Claude Code skill that scaffolds withrails g docs_kit:page, writes Markdown-first#content, and runs the verification gates. Written unless the site already has one.
Both point at the same recipe: one DocsUI::Section per part of the page (Sections own structure and the TOC — never a Markdown ##), prose via a single-quoted md <<~'MD' heredoc, and reference material via DocsUI::PropTable / DocsUI::FieldTable / DocsUI::RequestExample. See Authoring pages for the same contract written for humans.
docs_kit:page generator produces — see Authoring pages and Components for the full kit.