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.
One command
Section titled “One command”manni graph publish builds the graph and writes everything serving needs
into one directory:
$ manni graph publish -c test/graph/fixtures/dd/docs.config.yamlbuild graph.ttl: 3 pages, 137 triplesexport search.und.json, localizations.json: 14 entriesskipped embed: no graph.embed sectionexport graph.jsonld: 25 nodesexport chunks.json, chunks.und.jsonl: 11 chunks, 0 withheldskipped llms: no graph.export.llms sectionskipped iirds: no graph.export.iirds sectionPublished 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:
manni graph publish --out docs/public/graph && manni graph check -g docs/public/graph/graph.ttlThis 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.
One target at a time
Section titled “One target at a time”manni graph export writes a single target, for a pipeline that wants one
artifact or a different order. Each section below covers one.
manni graph export <target> [options]| Target | Writes | Read by |
|---|---|---|
jsonld | JSON-LD beside the graph | A linked-data ingester, a RAG pipeline, and the browser runtime. |
iirds | A conformant iiRDS package | A content delivery portal. RDF/XML is written inside the container. |
search | A lexical index per language, plus a localization manifest | @hawkeyexl/manni/graph/runtime, client-side. |
questions | One JSON line per reader question, with the text of each answer | An eval tool, such as Ragas, DeepEval or promptfoo. |
chunks | One JSON line per passage, each with its provenance record, plus a manifest | A retrieval host that serves passages with their proof. |
llms | llms.txt, one link per page with its description | An agent that reads the site’s index. |
$ manni graph export jsonld -c test/graph/fixtures/dd/docs.config.yamlWrote 25 nodes to .tmp/dd/graph.jsonldThe 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.
Linked data
Section titled “Linked data”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:
$ manni graph export iirdsmanni: 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 $?2Rebuild from the directory that holds every document, and export again. For a
collection, keep its paths: beneath the config file instead.
Search in the browser
Section titled “Search in the browser”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.
Semantic search
Section titled “Semantic search”manni graph embed adds a vector sidecar per language, which turns
manni graph search and the browser runtime from lexical matching into hybrid
retrieval:
manni graph export searchmanni graph embedmanni 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.5The configuration reference lists the tested models with what each costs in size and context.
Retrieval, in the page
Section titled “Retrieval, in the page”@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.
Questions for an eval tool
Section titled “Questions for an eval tool”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.
$ manni graph export questionsWrote 8 questions to graph/questions.jsonlEach 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.
Deciding what ships
Section titled “Deciding what ships”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.
What reaches an agent
Section titled “What reaches an agent”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: falsekeeps a field out of anything published.x-manni-location: externalkeeps 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.0marks the answer pins inside eachsample-questionsitem, 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 graph for readers
Section titled “A graph for readers”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:
$ manni graph publish --collection site --no-authoring -o .manni/graph/readersbuild graph.ttl: 262 pages, 20997 triplesexport search.en.json, localizations.json: 3023 entriesskipped embed: no graph.embed sectionexport graph.jsonld: 3107 nodesexport chunks.json, chunks.en.jsonl: 2458 chunks, 0 withheldexport llms.txt: 262 pagesskipped iirds: no graph.export.iirds sectionPublished 7 files to .manni/graph/readers/.
$ manni graph query -g .manni/graph/readers/graph.ttl --predicate dcterms:createdNo 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:
| Field | What a --no-authoring build leaves out |
|---|---|
created | dcterms:created. With the provenance derive source, git’s first commit fills it in. |
supersedes | The prov:wasRevisionOf it falls back to. |
provenance, meta-provenance | Their PROV triples. |
authors | The credits the frontmatter gives. |
citations | The pins on each passage. |
evals | The 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: falseEval 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.
Publish what passed
Section titled “Publish what passed” - 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/graphpublish 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.