Skip to content

Publish the graph

The graph is worth more outside CI than inside it. Linked data for an ingester. An iiRDS package for a content delivery portal. A search index the docs site loads in the browser. Passages with their proof, for an agent or a site’s Ask AI. The reader questions, for an eval tool.

This is Devin’s second page. The rule it carries: publish on the run that gated it, so what ships is what passed.

manni graph publish builds the graph and writes everything serving needs into one directory:

Terminal window
$ manni graph publish -c test/graph/fixtures/dd/docs.config.yaml
build graph.ttl: 3 pages, 137 triples
export search.und.json, localizations.json: 14 entries
skipped embed: no graph.embed section
export graph.jsonld: 25 nodes
export chunks.json, chunks.und.jsonl: 11 chunks, 0 withheld
skipped llms: no graph.export.llms section
skipped iirds: no graph.export.iirds section
Published 6 files to .tmp/dd/.

It builds, then embeds when graph.embed is configured. It always exports the search index, the linked data and the chunks. It writes llms.txt and the iiRDS package when their sections exist. A step that is not set up prints skipped and never fails the run.

-o names the directory; the default is the graph’s own. The first step that fails stops the run with exit 2 and that step’s own message.

publish does not gate, so check what it published. The check then reads the new chunks, so its cited row runs:

Terminal window
manni graph publish --out docs/public/graph && manni graph check -g docs/public/graph/graph.ttl

This site runs that line on every build, so it serves the artifact of the commit it shows. A failing check stops the build before anything deploys. The CLI reference lists every step and option.

manni graph export writes a single target, for a pipeline that wants one artifact or a different order. Each section below covers one.

Terminal window
manni graph export <target> [options]
TargetWritesRead by
jsonldJSON-LD beside the graphA linked-data ingester, a RAG pipeline, and the browser runtime.
iirdsA conformant iiRDS packageA content delivery portal. RDF/XML is written inside the container.
searchA lexical index per language, plus a localization manifest@hawkeyexl/manni/graph/runtime, client-side.
questionsOne JSON line per reader question, with the text of each answerAn eval tool, such as Ragas, DeepEval or promptfoo.
chunksOne JSON line per passage, each with its provenance record, plus a manifestA retrieval host that serves passages with their proof.
llmsllms.txt, one link per page with its descriptionAn agent that reads the site’s index.
Terminal window
$ manni graph export jsonld -c test/graph/fixtures/dd/docs.config.yaml
Wrote 25 nodes to .tmp/dd/graph.jsonld

The target is a positional, not a -f. Every other verb of the family means “output format” by -f, and one flag cannot mean two things.

-o writes somewhere else; the default is the graph’s path with the target’s extension. questions writes questions.jsonl beside the graph. An unknown target is exit 2, with the allowed set in the message.

chunks writes into the graph’s directory, or the one -o names. Each record carries the passage’s pins, dates and eval verdicts, as the CLI reference lists.

export jsonld is the general-purpose artifact. Every node is a JSON object with its IRI, and every edge a property, so a consumer needs no RDF library to walk it.

Give the corpus an HTTP baseIri before you publish it. The default, urn:manni:graph:, identifies without promising to resolve. That is honest for a graph that stays in CI, and unhelpful for one handed to someone else:

graph:
baseIri: https://docs.example.com/graph/

The namespace the custom terms use, graph:, resolves to a vocabulary document that defines every term the emitter can produce. That is not decoration: a consumer that meets graph:contentHash can look it up.

export iirds writes a package conformant with the tekom standard for intelligent information delivery. It carries package metadata you set in config:

graph:
export:
iirds:
title: Product documentation
creator: Example Corp
version: "1.3"

Quote the version. Unquoted, YAML reads 1.3 as a number and the value is refused. 1.2 and 1.3 are the two accepted.

Build from a directory that contains the corpus

Section titled “Build from a directory that contains the corpus”

A package has one root, and every document has to sit under it. manni graph build labels a typed path from where it ran, and a collection page from the config file’s directory. A corpus above that directory is therefore labelled ../external/page.md. That happens with manni graph build ../external/*.md, and with a collection whose paths: climb past the config file.

Only export iirds minds. build, query and the rest work on those paths as they always have. A container cannot hold a path that points outside itself, so the export refuses rather than writing an entry that escapes on extraction:

Terminal window
$ manni graph export iirds
manni: Cannot package "../external/page.md": an iiRDS package has one root, and a path that climbs above it has no place inside it. Re-run `manni graph build` from a directory that contains every document, then export again.
$ echo $?
2

Rebuild from the directory that holds every document, and export again. For a collection, keep its paths: beneath the config file instead.

export search writes the lexical index and a localization manifest, which is how a browser learns which locales exist before fetching any of them:

{
"version": 1,
"languages": [
{
"language": "und",
"documents": 3,
"search": {
"path": "search.und.json",
"entries": 14,
"digest": "sha256:50a4fd198f11f5782eca7852d7fff71f399c42b47cf8fc10147bcc2a8f420cde"
}
}
]
}

und is the undetermined language: this corpus declares none. Give the pages a language, or give the routes entries one, and the index fans out per locale. One file each, named from the tag. A corpus that ships several locales and one index serves the wrong one to most of its readers.

The digest is what the runtime checks a sidecar against, so a stale vector file is refused rather than silently mismatched. manni graph search checks the index against it too, and an index edited since the export is exit 2.

manni graph embed adds a vector sidecar per language, which turns manni graph search and the browser runtime from lexical matching into hybrid retrieval:

Terminal window
manni graph export search
manni graph embed
manni graph search "how do I install this"

It needs the optional @huggingface/transformers peer, and it runs on your machine: no text leaves it. --embedding-model mock runs offline and produces deterministic fixtures.

Embed per language. An English-only model over a German corpus returns confident, meaningless vectors and fails nothing, which is the failure mode embed.byLanguage exists to prevent:

graph:
embed:
model: onnx-community/granite-embedding-small-english-r2-ONNX
byLanguage:
de:
model: Xenova/bge-small-en-v1.5

The configuration reference lists the tested models with what each costs in size and context.

@hawkeyexl/manni/graph/runtime is the browser half: one file, no dependencies, no Node APIs. It loads the JSON-LD, walks it with scope rules honored, resolves node text, and assembles a bundle.

import { GraphIndex, traverse, createFetchResolver, assemble }
from "@hawkeyexl/manni/graph/runtime";
const graph = GraphIndex.fromJsonLd(await (await fetch("/graph/graph.jsonld")).text());
const { nodes, trace } = traverse(graph, {
seeds: ["https://docs.example.com/graph/doc/docs/configuration.md"],
depth: 2,
});
const bundle = await assemble(createFetchResolver(graph, { baseUrl: "/raw/", trace }),
nodes, { trace, maxChars: 12000 });

The runtime never generates. It returns context, citations and the trace that produced them, and stops. Wiring a model is the host’s job, and the trace is what lets an answer be argued with. See the library API reference.

For passages with their proof, read the chunks instead of walking the graph by hand. Retrieve in a browser or from a script builds a site’s Ask AI on createRetriever. Serve the docs to an agent hands the same passages to an agent over MCP.

export questions hands the golden set to a tool that judges answers. manni measures finding and judges nothing, so this is the path to the tools that do.

Terminal window
$ manni graph export questions
Wrote 8 questions to graph/questions.jsonl

Each line is one question, from a page’s sample-questions or a golden-set file. Each answer carries the text its pin holds, read from the page at export time, so the source stays pinned rather than copied. Drafts are included with reviewed: false, so filter them out before you score. The CLI reference has the line shape and the mapping into Ragas, DeepEval and promptfoo.

A published artifact should not carry every field a page holds. An owner, a review queue name, an internal ticket reference: each is useful to CI and none of it belongs in a graph you hand out.

Mark the property in the schema, not in a post-processing step:

{ "owner": { "type": "string", "x-manni-graph-output": false } }

The filter runs once, before derivation, so the field is absent from the jsonld, iirds and search exports and from the Turtle. export questions reads sample-questions from the page itself, so the mark does not apply there. A field kept in an external-metadata manifest is filtered the same way, because graph reads the page as manni meta validate does. Absent means true. The default set already marks owner, stakeholders and reviewed-by, the stewardship fields that name people. See Govern the graph.

Encrypted values need no mark. graph never decrypts, so an encrypted value reaches the graph as its ~… token. The graph records that a value exists and says nothing about what it is.

The chunks, their manifest, llms.txt and every served result hand page fields to an agent. One rule decides which:

A field reaches agent-facing output unless its schema marks it x-manni-graph-output: false or x-manni-location: external.

  • x-manni-graph-output: false keeps a field out of anything published.
  • x-manni-location: external keeps it out of the page, so a served page never carries it either.
  • Unmarked means delivered.
  • Marks are read at every depth. manni:ai-context:1.2.0 marks the answer pins inside each sample-questions item, so an agent gets the question without them.

The jsonld, iirds and search targets keep the first mark alone, because they are linked data rather than agent context. Delivery tools outside manni can read the same two marks. They are plain schema annotations.

A build carries author-facing content by default. That is the strategy records, typed, and every value a page’s schemas mark x-manni-location: external, at any depth. Those are the fields that prefer a sidecar, such as created, citations and evals. A maintainer’s graph wants them. A graph for readers, or for a retrieval service outside the team, leaves them out.

--no-authoring builds a graph of what the pages themselves carry:

Terminal window
$ manni graph publish --collection site --no-authoring -o .manni/graph/readers
build graph.ttl: 262 pages, 20997 triples
export search.en.json, localizations.json: 3023 entries
skipped embed: no graph.embed section
export graph.jsonld: 3107 nodes
export chunks.json, chunks.en.jsonl: 2458 chunks, 0 withheld
export llms.txt: 262 pages
skipped iirds: no graph.export.iirds section
Published 7 files to .manni/graph/readers/.
$ manni graph query -g .manni/graph/readers/graph.ttl --predicate dcterms:created
No matches.

Every strategy record leaves the build, as if no collection held it. A link to one is a broken link, as a link to any file outside the build is. Each value marked external leaves too, before derivation, so nothing derived from it reaches any artifact:

FieldWhat a --no-authoring build leaves out
createddcterms:created. With the provenance derive source, git’s first commit fills it in.
supersedesThe prov:wasRevisionOf it falls back to.
provenance, meta-provenanceTheir PROV triples.
authorsThe credits the frontmatter gives.
citationsThe pins on each passage.
evalsThe eval verdicts on each passage.

The chunk rows keep their shape. Each passage’s pins has a total of 0 and no items, and its evals is null. Its derived created day is null where only the field gave it. x-manni-graph-output: false keeps a field out of both graphs. audiences is a page field, so it stays.

A repository that publishes only to readers sets the default once:

graph:
authoring: false

Eval verdicts are author-facing, so a run without authoring refuses --evals with exit 2. A configured graph.export.chunks.evals warns once instead. The CLI reference has both messages.

- run: npx @hawkeyexl/manni graph build
- run: npx @hawkeyexl/manni graph check -f github
- run: npx @hawkeyexl/manni graph stats --check
- run: npx @hawkeyexl/manni graph publish --out site/public/graph

publish runs after the gates, in the same job. It rebuilds from the same commit, and a build over unchanged inputs is byte-identical, so what ships is what check passed. A separate publish job would check out again, which is a second chance to ship something nobody checked.