CGraph enriches a code graph with semantic knowledge — concepts, design intent, and links from docs to the exact code they describe — by having the host LLM author the annotations, never the binary. The tool plans bounded chunks of uncached documents, you (or an agent) read each chunk and drop a small JSON fragment, and CGraph validates and merges it into the graph through the same single-writer path that builds the deterministic layer. The result is one graph where parseConfig sits next to the design note that explains why it exists.
Why the binary never calls an LLM
The first design decision is the load-bearing one: the CGraph binary does not make LLM API requests and stores no model-provider credentials. Per the semantic-fragment-ingest spec, the tool "emits work for the host to perform and does not make an LLM API request itself." Enrichment is host-orchestrated.
That split matters for three reasons. Model choice and spend stay with you — CGraph doesn't pick a model or bill you for tokens. There are no API keys to leak from a background daemon. And the enrichment is reproducible in principle: the plan is deterministic, the merge is deterministic, and only the authoring step is model-driven — so the non-deterministic part is small, isolated, and yours to control.
The loop: plan, author, drop, ingest
Enrichment is a four-step loop you can run by hand or hand to an agent. It's the same loop CGraph's own cgraph-enrich skill drives.
Plan. cgraph enrich-plan walks your docs and media, skips anything already cached by content hash, and groups the rest into bounded chunks sized for a subagent:
cgraph enrich-plan --root . --drop cgraph-out/semantic-drop
It writes a plan.json manifest listing each chunk, its inputs, and — this is the clever part — a candidate_links array per document: real code-node ids the document appears to mention, matched deterministically from the graph's node labels. The matcher is high-precision on purpose: it accepts compound identifiers like classify_cached_file and capitalized type names, but rejects a bare lowercase cache that merely collides with a symbol name. Candidates are ids and labels only — the plan never picks the edge relation, because that's the author's judgment call.
Author. For each chunk, the host reads the documents and writes one node-link fragment capturing the concepts and how they relate — to each other and to the code:
{
"nodes": [
{"id": "doc:architecture", "label": "Architecture", "type": "document",
"source_file": "/abs/path/docs/architecture.md"},
{"id": "concept:daemon-lifecycle", "label": "Daemon Lifecycle", "type": "concept"}
],
"edges": [
{"source": "doc:architecture", "target": "concept:daemon-lifecycle", "relation": "DESCRIBES"},
{"source": "doc:architecture", "target": "graphd_serve_loop", "relation": "DOCUMENTS"}
]
}
Semantic ids are namespaced (doc:, concept:, topic:) so they never collide with code nodes, and an edge whose target is a real code-node id — pulled from the plan's candidates or a graph_query — is what stitches prose to code. That last edge above is the whole point: a documentation file, linked to the actual graphd_serve_loop node it explains.
Drop and ingest. Write the fragment to the filename the plan assigned (never one you invent — the numbering guarantees new fragments never overwrite an earlier pass), then merge:
cgraph enrich-ingest --root . --out cgraph-out
Ingest validates every dropped fragment and merges only the valid ones. A malformed fragment, or an edge pointing at a node that exists in neither the fragment nor the graph, is rejected atomically — the graph is left intact and enrichment_failed is incremented. There is no half-merged state and no dangling semantic edge. If a daemon is already running, dropping the file is enough: the watcher validates and folds it into the live snapshot within about 200ms.
Incremental by content hash
Enrichment is safe to re-run because it's cached at two levels, and both are content-addressed rather than timestamp-guessed.
The semantic cache keys finished fragments by document content hash. Enrich a file once and it's a cache hit on the next plan — it drops out of the work list entirely. Change the file and its hash changes, so it re-enters the next plan as stale and only that file is re-enriched. You can enrich the ten highest-value docs today (README, architecture, API guides — the ones that connect the most code) and leave the rest for later without redoing anything.
Underneath that, a stat index avoids even re-reading unchanged files. Planning records each file's size, mtime, and hash; on the next plan, a file whose size and mtime match is a stat hit and its stored hash is reused without opening the file. The index persists to the drop directory and survives a daemon restart. Here's that reuse on a real back-to-back plan of the CGraph repo:
The spec is strict that this is an optimization, not a behavior change: a plan computed cold (all hashed) and warm (all reused) must be "equal in chunk membership, content hashes, cache-hit count, and stale count." Reuse is invisible in the output — only faster.
Draining it autonomously
Because the loop is deterministic except for authoring, it can run unattended. A scheduled host-side drainer reads status.enrichment_pending per repo and, when it's zero, exits immediately with no model dispatch — steady state costs nothing. When it's positive, it runs the loop headlessly with a per-run chunk cap, leaving the rest for the next run so a huge backlog doesn't become one giant model bill. Model selection and spend stay host-owned, exactly as in the interactive loop.
You can watch it converge: graph_status reports enrichment_pending falling toward zero and, as fragments land, doc_nodes and doc_code_edges rising from zero — the concrete signal that prose is now wired into the graph.
Enrichment never blocks the deterministic layer. A doc changing marks its semantic entry stale, but code queries — query, impact, path — keep answering from the resolved code graph while enrichment catches up in the background.
Why bother: prose becomes queryable
The payoff is that design knowledge stops living only in Markdown nobody greps. Once a doc is linked to the code it describes, an agent asking for context around a symbol gets the design note alongside the source, and a concept node ties together the scattered files that implement one idea. It's the same fragment-ingest machinery that powers cross-service seams — a seam is just another host-authored fragment merged through this path — so enrichment and cross-service contracts share one validated, fail-loud merge.
Try it
Point it at a repo with real docs and plan the first pass:
cgraph enrich-plan --root . --drop cgraph-out/semantic-drop
Read a chunk, author one fragment, drop it, and run cgraph enrich-ingest --root . --out cgraph-out. Re-plan and watch the file drop out as a cache hit. The planner, the validator, and the merge path are all open source — read the code on GitHub — and the core concepts doc covers where enrichment sits relative to the deterministic pipeline.