One source, two audiences
Documentation has two readers with incompatible needs. A person wants a page: navigation, prose, a table of contents. An agent wants a passage: small, self-contained, and matched to a question. Most projects serve them from two pipelines, and the two drift.
This page is the worked example. It is rendered as HTML at
https://docspack.dev/docs/one-source-two-audiences, served as Markdown at the same path with
.md appended, and shipped as a chunk inside @docspack/docspack — all from this one file.
The two halves
docspack is the agent’s half. It indexes documentation packages into a local SQLite database
and answers from the versions a project actually installed. Nothing it does requires a website
to exist.
Sheaf is the reader’s half. It turns a directory of Markdown into a content graph — an ordered, serializable object with a slug, title, summary and headings for every document — and that graph renders the site. It is four packages, and you take only the ones you need.
@docspack/sheafThe content graph. No dependencies, no rendering.@docspack/sheaf-astroRenders the graph with Astro’s own Markdown pipeline.@docspack/sheaf-reactThe docs shell: sidebar, page, contents, pager.@docspack/sheaf-emitTurns the graph into a docspack package.The graph is the contract. Everything else reads it, and nothing reads anything else.
Why one parse rather than two
A folder of Markdown does not say which documents are published, in what order, or under what URLs. Two tools reading that folder each decide for themselves, and their answers diverge the first time a page is renamed or a draft is added. The site drops a page; the index keeps it.
A graph decides once. Drafts, ordering, slugs and headings are settled before either consumer sees them, so the site and the index cannot disagree about which documents exist or what they are called.
What a chunk cites
docspack build reads a folder, so the most it can record about a chunk’s origin is the file
it came from:
<!-- docspack: from 06-verify.md -->That is no use to whoever reads the answer. A graph knows where its pages are published, so a chunk emitted from one cites the page and the section:
<!-- docspack: from https://docspack.dev/docs/verify#what-it-reads-and-what-it-never-runs -->The answer an agent gives now carries a link a person can open. This package is built that way: all of its chunks cite a URL, and every one of those URLs is a route this site serves.
Building this package from the graph
The whole build is one file. It reads the directory, emits the payload, and says nothing else:
import { buildGraph } from "@docspack/sheaf";
import { emitDocspackPackage } from "@docspack/sheaf-emit";
const graph = await buildGraph("./docs");
await emitDocspackPackage(graph, {
out: ".",
name: "@docspack/docspack",
version: "1.0.0",
documents: ["docspack"],
baseUrl: "https://docspack.dev/docs",
});baseUrl is what turns a filename into a citation. Without it the emitter falls back to the
source path, which is what a folder-based build would have written anyway.
Sections split at every ##. A section over roughly 800 tokens splits again at its own ###
headings, because an oversized chunk crowds the rest of an answer out of the response budget —
the same escalation docspack build performs, and the reason docspack doctor warns above
1500.
Rendering the same graph as a site
Rendering belongs to the host, not to Sheaf. @docspack/sheaf-astro binds the graph to Astro’s
own Markdown processor — the same one that renders a .md file in src/pages — so a project’s
Markdown configuration keeps applying:
import { createPageRenderer } from "@docspack/sheaf-astro";
const renderPage = createPageRenderer({ syntaxHighlight: false });
const { html, headings } = await renderPage(page);The graph deliberately carries no HTML. That is what lets a React app, a Preact app and a static Astro page each use their own renderer, and what lets the emitter above ship Markdown rather than markup.
Blocks, where the two renderings differ
The list of packages above is a Markdown table. A reader sees it drawn; an agent is given the table. Neither rendering invents anything the other does not have, because the source wraps the table in a block rather than replacing it:
:::packages
| Package | What it does |
| --- | --- |
| `@docspack/sheaf` | The content graph. No dependencies, no rendering. |
:::toAgentMarkdown removes the block and keeps what it wrapped, and every agent-facing output
goes through it: the chunks in this package, the .md route for this page, and llms-full.txt.
The site’s renderer takes the same parse and replaces the block with a component. The rule is
structural, so the emitter needs to know no component names:
A block wraps content; it never replaces it. A block with nothing inside it is refused by file and line, because there would be nothing to give an agent where the page shows something — and a component the site has no renderer for fails the build for the same reason from the other side. A block that reached either audience as an absence is the one outcome neither half allows.
Anchors are the seam
A table of contents is built from the graph’s heading slugs. The id attributes it links to
are stamped by the renderer. Different code produces them, and a mismatch fails silently: a
fragment that matches nothing scrolls to the top of the page instead of raising an error.
It is worth guarding deliberately, because nothing reports it. The graph’s slugs are checked
against github-slugger and against the real Astro processor; the rendered page is checked for
the ids the graph predicted; and the URLs this package publishes are checked against the routes
the site builds.
Which half do you need
You can take either half alone.
- docspack only — you consume other people’s documentation packages, or you publish one
from a folder with
docspack build. No site, no graph, no Sheaf. - Sheaf only — you want a documentation site inside an app you already have, and you do not publish anything for agents. Take the graph and a renderer.
- Both — one directory of Markdown becomes the site people read and the package their agents answer from, and the two cannot describe different documentation.