AI & tooling

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 .md twin — every page has a GFM Markdown copy at GET /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 /mcp server exposing list_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.

app/controllers/docs_controller.rb
class DocsController < ApplicationController
  include DocsKit::Controller

  def show
    render_page(Views::Docs::Pages.const_get(params[:page].classify).new)
  end
end

The 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.

The host must route the .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 }.

SurfaceKindDefaultDescription
GET /docs/x.mdrouteThe page's GFM twin (.text is an alias).
DocsKit::Controller#render_pagemethodServes the twin on a .md/.text request, HTML otherwise.
DocsKit::MarkdownExport.new(view).to_mdclassThe HTML→GFM converter over #docs-content.
data: { md_skip: true }attributeOpt 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.

Hide the button site-wide with c.page_markdown_action = false — the .md route itself keeps serving the twin regardless; the knob only controls the UI.
SurfaceTypeDefaultDescription
DocsUI::MarkdownAction.new(path)Stringrequest.pathThe page whose .md twin the button targets.
docs-nav#copyMarkdownactionFetches + copies the twin; falls back to opening it.
c.page_markdown_actionBooleantrueShow 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:

config/routes.rb
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 .md link, in registry order,
  • a trailing ## MCP block 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.

Both endpoints are HTTP-cached: they revalidate on the rendered body plus 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.
SurfaceTypeDefaultDescription
c.brandString"Docs"The # H1 of the index (always emitted).
c.taglineString, nilnilThe > blockquote under the H1; nil/empty omits it.
c.nav_registriesHash{}Registries → the ## group sections and their .md links.
DocsKit::LlmsText.index / .fullclassThe 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}.
The endpoint is live only when BOTH the optional 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:

Gemfile
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:

config/routes.rb
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.

SurfaceTypeDefaultDescription
c.mcpBooleantrueToggle; 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 /mcprouteJSON-RPC; GET/DELETE 405 (stateless, read-only).
list_pages / get_page / search_docstoolsThe 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.md at 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 your AGENTS.md alone. A fresh site gets the whole file.
  • .claude/skills/write-docs-page/SKILL.md — a Claude Code skill that scaffolds with rails 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.

The skill's recipe is exactly what the docs_kit:page generator produces — see Authoring pages and Components for the full kit.