Bundle and products.json formats#
Two JSON files connect product sites to the hub and to the MCP server. corpus build writes docs-bundle.json next to each product site. The hub repository maintains products.json.
docs-bundle.json#
Schema v2 adds purpose and builtOn from the manifest. build runs check, which refuses the exact placeholder purpose that corpus init writes, so a scaffolded sentence never reaches a built bundle. The MCP reader and hub accept both v1 and v2 bundles; v1 has no purpose metadata. build writes only v2 bundles, because only v2 manifests are read; v1 bundles that were published earlier stay readable. The bundle's schemaVersion is its own contract, separate from the manifest's. list_products includes both new fields for v2 bundles. via is a string or a non-empty array of package/CLI strings grouped under a declared product id. Package-to-product ownership and product ids are not verified offline. A building block may include "evidence": "cli"; the manifest check verifies its via command in package scripts or workflow run steps and keeps the reason in for. An optional bare bin string names a CLI executable differing from the via package name; it is invocation evidence, not verified package-to-bin ownership. GitHub expression values are neutralised before checking invocations; an untokenisable command line provides no evidence, other lines of the same step still do, and the failure names the skipped lines. This metadata is preserved in the bundle and accepted by MCP readers. Other evidence values are configuration errors.
The content digest algorithm remains unchanged; it identifies document content, not product metadata.
{
"schemaVersion": 2,
"purpose": "Make process and execution state visible to users.",
"builtOn": [],
"product": "flow",
"title": "Flow",
"tagline": "Process transparency for agentic work",
"repo": "kontourai/flow",
"sha": "<git rev-parse HEAD at build time>",
"builtAt": "2026-10-10T12:00:00.000Z",
"siteUrl": "https://kontourai.github.io/flow/",
"digest": "<sha256 hex>",
"documents": [
{ "source": "README.md", "section": null, "title": "Flow", "landing": true, "url": "https://kontourai.github.io/flow/index.html", "repoPath": "README.md", "lineOffset": 0, "markdown": "# Flow\n..." },
{ "source": "docs/getting-started.md", "section": "Start Here", "title": "Getting started", "landing": false, "url": "https://kontourai.github.io/flow/docs/getting-started.html", "repoPath": "docs/getting-started.md", "lineOffset": 4, "markdown": "# Getting started\n..." }
],
"topics": [
{ "id": "workflows", "title": "Workflows", "source": "docs/concepts.md#workflows", "url": "https://kontourai.github.io/flow/docs/concepts.html#workflows", "markdown": "## Workflows\n..." }
]
}
documents[0]is the landing page, withlanding: trueandsection: null. The listed documents follow in manifest order, withlanding: false. A document'stitleis thetitleset on itsdocumentsentry incorpus.json, if any. Otherwise it is its first Markdown H1 outside blockquotes and lists, then the text of the first raw-HTML<h1>that has text, then a simple front-mattertitle:, then the file name without.md.markdownis the source file as written, minus a leading YAML front-matter block.- Each document's
urlis the pagebuildgenerates:index.htmlfor the landing page, otherwise the source path with.mdreplaced by.html, undersiteUrl. A bundle-only document ("publish": false) has no page, so itsurlishttps://github.com/<repo>/blob/<sha>/<path>(HEADin place of<sha>whenmcpserves a directory outside git), and itsmarkdownhas relative links made absolute (images and other embedded files tohttps://raw.githubusercontent.com/<repo>/<sha>/<path>), as are topics taken from it. Published documents keep their Markdown as written. Redirect stubs from the manifest'sredirectsare not listed; the bundle has no field for them. The manifest'sdecisionsblock is not in the bundle either; decision records reach it only as listed documents. Declaringdecisionsletschecktreat the records and the index as reachable without adocumentsentry, but an unlisted record is still left out of the bundle and the site. repoPathandlineOffset(since 0.6.0) say wheremarkdownsits in the repository:repoPathis the source path from the git top level (it differs fromsourceonly when--rootis a subdirectory), andlineOffsetis the number of front-matter lines stripped beforemarkdown's first line, so line N ofmarkdownis line N +lineOffsetof the file. The MCPlocatetool uses them for file line numbers and GitHub links. Both are optional for readers: a bundle built before 0.6.0 has neither, andlocatethen assumes no front matter and says so. Neither is part ofdigest.- A topic may carry three optional hierarchy fields:
parentId, theidof another topic in the same bundle;tags, an array of non-empty strings; andsummary, a string.corpus builddoes not write them yet; a host that generates its own bundle (for example, from a catalog of synthesized topics) does. The sources that serve written bundles validate them: aparentIdthat names no topic in the bundle, a chain of parents that loops, or a topicidused twice makes the bundle malformed, somcp --hubreports the product unavailable andmcp --bundle(andstaticSource) refuse it.mcp --repoand--workspacebuild their bundles fromcorpus.json, which cannot declare these fields and already refuses repeated topic ids. A topic withoutparentIdis a root.list_topicsreturns the fields and filters byparentId;searchscores a tag hit like an id hit (3) and a summary hit at 2, between a title (5) and a body (1) hit. - A topic's
markdownis a heading section: it runs from the anchor heading up to the next top-level heading of the same or a higher level. Deeper headings stay in the section. A topic without an anchor gets the whole document. taglineis""when the manifest omits it.digestis the SHA-256 (hex) ofJSON.stringify({documents, topics}). Documents are reduced to{source, section, title, landing, markdown}and topics to{id, title, source, markdown}, followed byparentId,tags, andsummaryin that order when a topic has them (so a bundle without them hashes as it always did), in manifest order, withmarkdownas written (before bundle-only link rewriting, which embeds the sha and site URL). URLs,sha, andbuiltAtare left out, so the digest identifies the content alone.- The hub's
thinstate and the MCPdocCountcount only documents withlanding: false.
mcp --bundle <file> reads a written bundle once at startup and serves it unchanged until the server exits; a malformed one, or two for one product, is a usage error (exit 2). mcp --repo and mcp --workspace build the same object in memory on every tool call. They read only corpus.json and refuse a retired docs-kit.json; --workspace serves each immediate subdirectory that has corpus.json, skips one with only docs-kit.json (noting it once on stderr), and reports a directory that has both as unavailable. Outside a git checkout, their sha is null. They add dirty: true when the working tree has uncommitted changes. The MCP server validates every bundle down to each document and topic; a malformed one is reported as unavailable.
products.json#
The hub's product list, validated strictly. An unknown key is refused, and each product must equal the repository name in its repo:
{
"schemaVersion": 1,
"products": [
{ "product": "flow", "repo": "kontourai/flow" }
]
}
corpus hub copies it next to index.html. corpus mcp --hub <url> reads <url>/products.json and then <url>/<product>/docs-bundle.json for each product.