1. Executive Summary
Create Context Graph is a Neo4j Labs CLI —
uvx create-context-graph or
npx create-context-graph — whose interactive wizard writes
out a complete agent application: a FastAPI backend, a Next.js and
Chakra UI frontend with an NVL graph view, a domain ontology, Cypher
schema, fixtures, and an agent against one of eight frameworks. Its
import machinery is careful work: per-connector watermarks that advance
only after a clean batch, a drainable deadletter, and a parity test over
two deliberately duplicated write paths. The memory it scaffolds cannot
correct or delete a single memory, encodes relationships on its default
backend into text nothing reads back, and hands the agent a Cypher tool
described to the model as read-only that writes freely on a self-hosted
graph. Version 0.14.0 at the pinned commit of 4 September 2026.
The scope call, stated up front. A scaffolder is not
a memory system. This one is in scope on two counts, and both are about
code in this repository rather than about what it generates in the
abstract. First, src/create_context_graph/ingest.py is a
1,077-line write path that the CLI runs itself: thirteen connectors pull
real GitHub, Slack, Gmail, Jira, Notion, Linear, Google Workspace,
Salesforce, Claude Code, Claude AI and ChatGPT history and this file
writes it into a graph that outlives the run. Second, the memory
mechanisms of the generated app live in templates committed here —
templates/backend/shared/memory.py.j2,
memory_adapter.py.j2,
context_graph_client.py.j2 and
connectors/import_data.py.j2 — and those templates are the
artifact judged below.
The memory engine itself is a dependency, neo4j-agent-memory pinned as
>=0.4.0,<0.6.0 in the generated
pyproject.toml. Short-term, long-term and reasoning tiers,
extraction and embeddings all belong to it. What this repository
contributes is the compatibility layer for the hosted Neo4j
Agent Memory Service, which became the default backend in v0.12.0, and
that layer is where the interesting engineering and the interesting
damage both are.
The hosted REST surface accepts only
{name, type, description} on an entity and has no
add_relationship. The response is a hybrid write
shape: every non-name property is rendered into description
as a markdown block, and every outbound edge is appended to the same
field as a fenced ccg-edges YAML block, deterministically
sorted, documented in docs/docs/explanation/ccg-edges.md,
and mirrored in three implementations whose bodies are identical at this
commit. A contract test holds two of them in lockstep; the third, in
memory_adapter.py.j2 behind make seed, has
only the block's presence asserted
(tests/test_memory_adapter.py:479-484). It is a careful,
honest workaround.
It is also, at this commit, write-only:
grep -rn 'ccg-edges' src/create_context_graph/templates/frontend/
returns nothing, the only readers of the marker
(memory_adapter.py.j2:240-241, :263-264) split
on it and keep the text before it, and no migration script
exists. The README's "the frontend graph view parses them out and
renders edges" and the generated project README's "the frontend parses
these blocks to render edges" are not supported by the tree.
Correction and forgetting are absent, and the code says so
plainly. _reset_nams
(src/create_context_graph/ingest.py:924-959) counts what is
stored and then prints that it cannot remove it, with a comment
recording that an earlier version "silently reported '0 entities
removed' by swallowing AttributeErrors; being honest beats pretending."
On bolt the only eraser is MATCH (n) DETACH DELETE n.
Nothing in the tree supersedes, expires, tombstones or deletes an
individual memory.
Strongest parts: the generated
import_data.py, whose per-connector watermarks advance only
after a clean batch and whose failures append to a drainable JSONL
deadletter. Beside it, tests/test_nams_ingest_parity.py
drives the CLI path and the rendered template against one fixture and
diffs the captured call sequences. The Claude Code connector's redaction
is called on message content, tool output, error messages and document
bodies rather than at one chokepoint. Weakest: the
agent's own read path has no scope predicate in any of the twenty-seven
bundled domains, and the test that was written to catch that asserts
len(domain_scoped_tools) >= 0. One mark,
negative_eval. scope_enforced is withheld:
those tools query the same graph as the nine REST routes that filter on
the domain, and their Cypher cannot carry the predicate.
2. Mental Model
A memory here is whatever a connector or a chat turn hands to
neo4j-agent-memory, and the system's own epistemics are
thin by design: it decides what shape a thing is written in,
not whether it is true. Three kinds survive a session.
A message is appended verbatim on every turn, user
and assistant, by store_message
(templates/backend/shared/memory.py.j2:387-428). The
library extracts entities from it; on the hosted backend preference
detection is forced off (config.py:143-148), so that half
of the extraction never runs at the default.
An entity is a name plus a POLE+O type plus one
description string. On bolt, an entity written by make seed
also carries typed attributes and a domain; the CLI's
library branch packs both into one metadata string. On the hosted
backend add_entity is called with three keyword arguments
and nothing else (ingest.py:351-355), so the type system is
the description's _pole_type: X_ suffix and a markdown
attribute list. Identity is the name: re-ingest MERGEs onto it, which is
the only deduplication in this tree.
A decision trace is a task, an ordered list of
thought/action/observation steps, and an outcome, written through the
reasoning API with success=True hardcoded for every fixture
trace (ingest.py:455-459).
Nothing moves between states. A memory is created and then is
permanent: no valid_from/valid_until, no
status column, no archival, no TTL — searches for all of them are in the
appendix. The closest thing to a rejection record is the Claude Code
connector's correction detector
(connectors/_claude_code/decision_extractor.py:162-269),
which reads a user message matching one of ten correction patterns,
writes the preceding assistant turn as an Alternative with
wasChosen: False and
reason: "User corrected this approach", and links it
REJECTED from a Decision node. That is a
durable record of something a person rejected.
Its name is alt-{decision_id}-original, where
decision_id is twelve hex digits of a SHA-256 over the
session id, the message index and correction
(:211, :489-492). The key is where the turn
sat in a transcript rather than the value, so a later import of the same
bad suggestion produces a fresh node and walks straight past it.
tombstone is withheld on exactly that.
Agency sits with the ingest pipeline and the library by design. The generated agent has no memory verb; it queries a graph someone else populated, and its own turns are captured for it. On a self-hosted scaffold that boundary is a sentence in a tool description rather than a session mode, as section 8 sets out.
Diagram source
%% caption: one ontology drives two backends that diverge at the write, and on the hosted default every relationship lands inside a text field that no reader in the tree parses back out
flowchart TD
SRC["connector fetch or LLM-generated fixtures: entities, relationships, documents, traces"] --> ING["ingest.py run_nams_ingest / _ingest_with_memory_client"]
ING --> BK{"memory_backend"}
BK -->|"bolt, --self-hosted"| B1["make seed MERGEs on {name, domain}; CLI library ingest and make import write no node-level domain"]
B1 --> B2["relationships as native edges via Cypher MERGE"]
B2 --> B3["Document nodes plus MENTIONS edges"]
B3 --> BR["read: agent tools and run_cypher unfiltered and writable; nine REST paths filter n.domain, null-permissive"]
BK -->|"nams, default"| N1["add_entity name, type, description only"]
N1 --> N2["properties flattened into description as markdown"]
N2 --> N3["outbound edges appended as a fenced ccg-edges YAML block"]
N3 --> N4["documents dual-tracked: entity plus short_term.add_message"]
N4 --> NR["read: memory_adapter strips the block and keeps the text before it"]
NR --> DEAD["no parser anywhere: the encoded edges are never read back"]
BR --> DEL{"forget one memory?"}
NR --> DEL
DEL -->|"bolt"| W["designed eraser: MATCH n DETACH DELETE n, the whole store"]
DEL -->|"nams"| X["no delete endpoint; reset prints a refusal"]3. Architecture
Two artifacts, one repository. The CLI is a Click
application (cli.py, 748 lines) with a Questionary wizard
(wizard.py, 600 lines). It loads a domain YAML through
ontology.py into a DomainOntology Pydantic
model, optionally generates fixtures with an LLM
(generator.py), renders a project tree through Jinja2
(renderer.py, 584 lines), and can then ingest into the
chosen backend. Twenty-seven bundled domain YAMLs plus a
_base.yaml they inherit from, and a matching pre-generated
fixture JSON for each.
The generated app is a FastAPI backend
(main.py, routes.py, agent.py,
context_graph_client.py, memory.py,
memory_adapter.py, gds_client.py,
vector_client.py), a Next.js frontend,
cypher/schema.cypher, data/ontology.yaml and
data/fixtures.json, a Makefile and a docker-compose for
local Neo4j.
Deployment, which differs sharply by backend. The
default path needs a NAMS API key from memory.neo4jlabs.com
plus an LLM key for the agent; nothing is stored without the API key,
and connect_memory raises if
MEMORY_BACKEND=nams and MEMORY_API_KEY is
unset (memory.py.j2:150-154). Nothing runs locally: no
Neo4j, no embedder — _resolve_embedding_model returns
None on NAMS specifically to avoid pulling
sentence-transformers and torch (:120-132). The
--self-hosted path needs a Neo4j 5+ instance (Docker, Aura
or neo4j-local), pulls the library's
[sentence-transformers,extraction,fuzzy] extras, and
defaults embeddings to local all-MiniLM-L6-v2. Either way
the generated .env holds every key in plaintext; it is
gitignored, but .context-graph/ and
data/fixtures.json are not
(templates/base/gitignore.j2), so an imported Slack or
Gmail corpus and a deadletter of failed writes land in a tracked path by
default.
No background processing exists in the generated
app. main.py.j2's lifespan connects Neo4j and
memory, calls create_vector_index() once, and that is the
whole of it — no worker, no queue, no scheduled consolidation. Seeding
and importing are make targets a person runs.
Degradation is deliberate and well-covered. Startup
catches its own failures and sets a degraded flag rather than exiting;
_classify_memory_error (memory.py.j2:63-106)
buckets an exception into auth, rate_limit,
network, config or unknown by
status code, then exception type, then message substring, and
/health reports the bucket. store_message
writes into the same state on a live failure, and treats the library's
swallowed {"error": ...} return as a failure rather than a
success (:410-418) — a fix whose absence, per
CLAUDE.md, meant conversation memory "was silently failing
on every message".
4. Essential Implementation Paths
- Scaffold-time write, hosted.
ingest_data(ingest.py:1011-1077) →_ingest_with_nams(:470-548) →run_nams_ingest(:274-467). Stage 0 binds the workspace ontology (ensure_nams_ontology,:219-271), returning one ofalready-active,activated,createdorunavailable. Stage 1 writes entities:_serialize_entity_to_description(:84-115) renders every property outside{name, description, domain, id, uuid}as**Key**: valueand appends_pole_type: X_;_description_with_edges(:180-189) appends theccg-edgesblock;add_entitytakes the result. An entity whose connector declaresBODY_FIELDSalso goes throughshort_term.add_messageas extraction fuel. Stage 2 writes documents twice. Stage 3 writes traces. - Scaffold-time write, bolt.
_ingest_with_memory_client(:556-730) applies the generated Cypher schema, callsadd_entitywithattributes={**item, "domain": domain_id}(:609-615), MERGEs each relationship as a native edge behind_require_safe_cypher_identifier(:126-130), MERGEs:Documentnodes and linksMENTIONSby substring containment of an entity name in the document body (:675-684), and writes:DecisionTrace/:TraceStep._ingest_with_driver(:733-901) is the same without the library, and MERGEs entities on{name, domain}(:787). The CLI takes the library branch wheneverneo4j_agent_memoryimports, which the optionalingestextra installs, and the driver branch otherwise (ingest.py:1059-1077,pyproject.toml:41-43). - What the library does with those attributes. At
neo4j-agent-memory's
python-v0.5.0tag (8bd50eff…),add_entityMERGEs(e:Entity {name: $name, type: $type}), adds only the POLE type as a label, and serializesattributes,domainincluded, into onee.metadatastring (memory/long_term.py:504-525,graph/query_builder.py:270-291there). The ontology label and adomainnode property never reach an entity written this way. The CLI'suv.lockresolves that extra to 0.0.5, which has no tag in the library's repository and which I did not read. - Bolt writes in the generated project.
make seedrunsgenerate_data.py, which MERGEs every entity on{name, domain}with ontology labels (templates/backend/shared/generate_data.py.j2:68-73).make importruns_ingest_via_bolt(templates/backend/connectors/import_data.py.j2:428-), which MERGEs entities on{name}alone (:485) and documents on{title}alone (:547) and stamps nodomainon either. - Runtime write. Every framework's
handle_messagecallsresolve_session_id,store_message(session_id, "user", message),get_context, the agent, thenstore_message(..., "assistant", ...)—templates/backend/agents/pydanticai/agent.py.j2:106-146and the same shape in the other seven. - Hosted session mapping.
_resolve_nams_conversation(memory.py.j2:365-384) creates a conversation on first use and caches the server-assigned id in a module-level dict, because the service rejects client-chosen ids. The comment is explicit that this is process-local and that a restart therefore resets context rather than failing every write. - Retrieval.
execute_cypher(context_graph_client.py.j2:253-287) dispatches toclient.query.cypheron NAMS and to a driver session on bolt.search_entities(:290-305),get_entity_graph(:308-322),get_schema(:325-352),get_schema_visualization(:354-) andexpand_node(:419-432) are the domain-filtered helpers behind the REST routes, andlist_documents,get_document,list_tracesandget_entity_detailinroutes.py.j2(:327-440) filter the same way;memory_adapter.py.j2holds the NAMS equivalents, each cypher-first with a REST search fallback. - Incremental import in the generated project.
templates/backend/connectors/import_data.py.j2—_load_watermarks/_save_watermarks(:89-101),_append_deadletter(:104-107),main(:693-774) which advances watermarks onlyif not any_failure, and_retry_deadletter(:777-) which renames the file to.jsonl.retryingbefore replaying it so a failed retry does not lose records. - Correction and preference extraction from local
sessions.
connectors/_claude_code/decision_extractor.py(492 lines, four detectors: corrections, deliberations, error resolutions, dependency changes) andpreference_extractor.py(213 lines, fifteen explicit patterns plus package-frequency inference with_MIN_PACKAGE_SESSIONS = 2and_MIN_CONFIDENCE = 0.4). - Redaction.
connectors/_claude_code/redactor.py:23-61— fourteen patterns for Anthropic, OpenAI, GitHub, Slack and AWS credentials, bearer headers, generic key and password assignments, credentialed connection strings and.env-style variables — called atclaude_code_connector.py:252,:306,:382and:411. - Tests covering the above.
tests/test_nams_ingest_parity.py(625 lines),tests/test_memory_adapter.py(492),tests/test_ingest_nams.py(556),tests/test_generated_client_runtime.py(695),tests/test_integration.py(414,--integrationonly).
5. Memory Data Model
There is no schema of the system's own. What exists is a
generated schema per domain plus the library's, and the two
backends produce structurally different graphs from the same ontology —
ingest.py's module docstring says so in as many words.
| Concern | Bolt (--self-hosted) |
NAMS (default) |
|---|---|---|
| Entity identity | MERGE (n:Label {name, domain}) from
make seed and the CLI's driver branch; {name}
alone from make import;
(e:Entity {name, type}) from the CLI's library branch |
add_entity(name=…), server-side merge by name |
| Properties | native node properties, plus domain from
make seed; one serialized e.metadata string on
the library branch |
one markdown block inside description |
| Relationships | native typed edges via MERGE |
a fenced ccg-edges block inside the source entity's
description |
| Documents | :Document MERGEd on {title} with
content, template_id,
template_name, and domain everywhere but
make import, plus MENTIONS |
an OBJECT entity and a short_term message |
| Traces | :DecisionTrace and :TraceStep with
HAS_STEP |
the reasoning REST API |
| Constraints and indexes | generate_cypher_schema
(ontology.py:411-478) emits uniqueness constraints,
per-label name indexes, a full-text index over Document and Section, and
a commented-out vector index |
server-owned; the CLI prints that it is skipping schema DDL |
Temporal fields are absent. The only timestamps are
content — timestamp on a Claude Code decision,
loadedAt/createdAt/modifiedAt on
a local-file Document — and none is a validity bound distinguished from
a record time. bitemporal is withheld.
Scoping is the domain string, and its
writers decide what it protects. make seed MERGEs it into
the node key (generate_data.py.j2:73), so two seeded
domains sharing one Neo4j instance cannot collide on a name. The CLI's
library branch packs it into e.metadata, and
make import writes none, so imported entities of one label
merge across domains by name.
It is read back under
(x.domain IS NULL OR x.domain = $domain) on nine paths:
five helpers in context_graph_client.py.j2
(:296, :314, :331,
:371, :427) and four handlers in
routes.py.j2 (:341 and :354,
:378, :398, :424). The predicate
is permissive, so every node written without the property,
library-written entities included, is visible from every domain.
expand_node filters neighbours and not the node it is
handed by elementId. The front end's schema-node click
sends MATCH (n:<label>)-[r]-(m) through
POST /cypher with no predicate
(ContextGraphView.tsx.j2:238-243).
The agent reads the same graph, and only its schema tool, which calls
the scoped get_schema, applies the key. Each domain tool's
body is a fixed Cypher string run through execute_cypher,
the function the nine paths call
(agents/pydanticai/agent.py.j2:63-76,
context_graph_client.py.j2:253-287). None of the 193 bodies
across the bundled domains names $domain, nor do the
connector tools renderer.py appends; the searches are in
the appendix. run_cypher defaults a domain
parameter in all eight templates
(pydanticai/agent.py.j2:86), so the predicate applies only
when the model writes it into its query.
scope_enforced is withheld on that: an agent-reachable
read over the same store that cannot carry the predicate, beside a
free-form one bounded by the prompt. The nine REST routes are the
near-miss. On the hosted backend the property is never written, so the
boundary there is the workspace behind the API key — a physical
partition, which is a real boundary and not the mark.
Provenance is by convention rather than field. A
metadata dict with kind: "document" or
kind: "entity-body" and the entity name rides along on
short_term.add_message (ingest.py:379-385),
and _pole_type: X_ is described in the code as "this
scaffold's own write contract" for surviving the service's coercion of
unknown entity types to custom
(memory_adapter.py.j2:282-286).
6. Retrieval Mechanics
The agent reads the graph with Cypher and nothing
else. Each domain YAML declares seven or more tools whose
bodies are literal Cypher strings interpolated into the framework
template (agents/pydanticai/agent.py.j2:63-76), plus a
free-form run_cypher and a get_graph_schema.
The system prompt insists the model call a tool before answering
anything about the data. There is no ranking, no fusion, no token
budget: a tool returns json.dumps(result) of whatever rows
came back, and each domain tool's own LIMIT is the only
bound.
Lexical matching is substring containment.
search_entities is
toLower(n.name) CONTAINS toLower($query) OR toLower(coalesce(n.description, '')) CONTAINS toLower($query)
with LIMIT $limit. On the hosted backend the equivalent
delegates to client.long_term.search_entities(query=…),
whose ranking belongs to the service.
The vector index has no reader.
main.py.j2:71-75 calls create_vector_index()
at startup, creating entity_embeddings on
(:Entity).embedding at 1536 dimensions. No code in this
tree writes that property; the library does, setting
e.embedding on :Entity in
add_entity at its python-v0.5.0 tag whenever
an embedder is configured. The bolt default embedder here is local
all-MiniLM-L6-v2 (memory.py.j2:120-132), which
emits 384 dimensions. Neither vector_search nor
hybrid_search in vector_client.py.j2 has a
caller anywhere; both searches are in the appendix.
The ontology's own vector index is emitted as commented-out Cypher with a note that dimensions must match the embedder. So the generated app's retrieval is graph traversal plus substring matching, and the vector file is scaffolding an adopter has to finish.
The most consequential finding in this section is what the context call throws away. Every framework does:
context = await get_context(session_id, query=message)
history = context.get("messages", [])
get_context returns
{"messages", "entities", "preferences", "traces"}
(memory.py.j2:431-449). All eight templates bind only
messages; entities, preferences
and traces are fetched over the network and dropped —
grep -rn 'context\.get(' src/create_context_graph/templates/backend/agents/
returns sixteen lines and every one of them is
context.get("messages", []). The extraction results that
come back from the write (assistant_result) are
forwarded to the browser as entities_extracted and
preferences_detected SSE badges and are not put in front of
the model either. So the long-term tier the README calls the point of
the design reaches the agent only if the model happens to write a Cypher
query that finds it.
Failure modes that follow. On the hosted backend,
the document browser enumerates by
n.description CONTAINS '_pole_type: OBJECT_' — but that
marker is written onto every entity whose ontology POLE type is
OBJECT, which is 102 entity types out of the 170 across the bundled
domains. A Diagnosis and a Medication are returned as documents.
_document_record_from_fields only rejects a record whose
description is empty after the marker and edge block are stripped, and
an entity's attribute list is not empty.
schema_visualization_nams always returns
"relationships": []. list_traces_nams is
written as an N+1, every trace and then
get_trace_with_steps per trace. At the library's
python-v0.5.0 tag list_traces raises
NotSupportedError on the hosted client
(nams/reasoning.py:331-332 there), the adapter catches it,
and the traces panel is empty on the default backend
(memory_adapter.py.j2:381-385).
7. Write Mechanics
Chat writes are synchronous and doubled.
store_message is awaited before the agent runs and again
after it produces text, and the library performs entity extraction
inside that call — an LLM round trip on the user's turn before the agent
has started thinking. A memory is retrievable as soon as the call
returns; there is no deferral and therefore no lag to state. Extraction
defaults to anthropic/claude-haiku-4-5 when an Anthropic
key is present, openai/gpt-4o-mini when only an OpenAI key
is (memory.py.j2:109-117); with neither, extraction is
disabled and messages are still stored.
Ingest writes are one record at a time, with per-record
failure isolation. run_nams_ingest wraps every
add_entity, add_message,
start_trace and complete_trace in its own
try/except, appends a failure record with a kind and a name, and returns
counts alongside failure_records. The CLI prints the count;
the generated import_data.py writes each record to
.context-graph/deadletter.jsonl instead, which is the
better half of the same design.
Deduplication in this tree is MERGE on a name key and nothing
more. On bolt, the seeder's
MERGE (n:Label {name, domain}) ON CREATE SET … ON MATCH SET …
overwrites in place, so a re-import replaces properties with no record
that they changed. On NAMS the service merges by name. The library's
add_entity adds embedding-similarity deduplication when an
embedder is configured, which is its mechanism rather than this
repository's. Two different people called "J. Smith" are one entity in
both. There is no conflict detection, no contradiction pass and no
consolidation anywhere in the tree.
Deletion. reset_memory_store
(ingest.py:962-975) branches: bolt runs
MATCH (n) DETACH DELETE n, NAMS runs
_reset_nams, which counts and refuses. The generated
Makefile mirrors both — make reset on the hosted backend
prints "NAMS exposes no delete API (neo4j-agent-memory 0.5.x) — reset is
not available from the CLI." A test asserts the refusal path calls no
delete (tests/test_ingest_nams.py:437,
delete_entity.await_count == 0). No designed operation
narrower than the whole store exists on either backend; on bolt,
POST /cypher and the agent's Cypher tool run a targeted
DETACH DELETE as readily as a read (section 9).
Noisy and hostile input. Bolt ingest validates every
interpolated label and relationship type against
^[A-Za-z_][A-Za-z0-9_]*$ before it reaches an f-string, and
passes names as parameters — this is the one place the repository treats
connector data as untrusted. Redaction is applied to Claude Code content
on import. Beyond that, imported Slack messages, Jira descriptions and
Gmail bodies become entity descriptions that the agent reads back as
tool results; nothing filters instruction-shaped text.
No background pass rewrites the store, so there is no corpus-scaled token bill. The cost is proportional to the day's messages, plus whatever the LLM fixture generator spends once at scaffold time.
8. Agent Integration
Eight frameworks, one shape. PydanticAI, Claude
Agent SDK, OpenAI Agents, LangGraph, CrewAI, Strands, Google ADK and a
hand-rolled Anthropic tool loop. Each template differs only in how tools
are registered and how streaming is collected; the memory calls, the
session handling and the HTTP layer are identical. That uniformity is
the scaffolder's real product and it is executed cleanly.
tests/test_matrix.py scaffolds every bundled domain against
every framework, 216 combinations at this commit, in the
full-suite CI job; the README says 176 and the test's
docstring 184.
The agent has no memory verbs, and its read tool is not
read-only on bolt. It has no tool to save, forget or correct a
memory; its affordance is Cypher over the graph and a message history
the harness injects. Every framework template registers a free-form
Cypher tool described to the model as read-only
(agents/pydanticai/agent.py.j2:80-91, and the same in the
other seven). On bolt it reaches execute_cypher, which runs
the string in a default session
(context_graph_client.py.j2:273-287), so a
MERGE, SET or DETACH DELETE the
model writes is executed. On NAMS, the project's changelog records the
service rejecting write queries (CHANGELOG.md:45). Capture
is automatic and invisible to the model.
Sessions. --session-strategy picks
per_conversation (default), per_day or
persistent, passed to the library's
SessionStrategy at construction
(memory.py.j2:261-282). The browser keeps the id in
sessionStorage under
ccg-session-id-${DOMAIN.id}
(ChatInterface.tsx.j2:51, :130,
:192), so it dies with the tab, as does the chat history
kept beside it under ccg-chat-history-${DOMAIN.id}
(:50, :191). On the hosted backend the
server-id mapping is a module-level dict, so a backend restart silently
starts a new conversation for a returning session id. There is no
compaction-boundary handling; nothing bounds how much history
get_context(max_items=10) returns beyond that default.
MCP. --with-mcp writes a Claude Desktop
config that launches
python -m neo4j_agent_memory.mcp.server against the same
backend (templates/base/mcp/claude_desktop_config.json.j2).
The profile is coerced to core on NAMS because the extended
tools need preference and fact endpoints the REST API does not expose
(config.py:136-141). The server is the library's; this
repository contributes the config and the coercion.
Adapting it to another stack is easy in one
direction and not the other: the memory surface is four functions in one
module, so swapping frameworks is trivial, but the whole design assumes
a Neo4j-shaped store behind neo4j-agent-memory and nothing
abstracts that.
9. Reliability, Safety, and Trust
The hosted write path is lossy in a way the code documents
and the docs overstate. Structured entity properties become
prose inside description; relationships become YAML inside
the same field. The encoding is deterministic and grep-able, the docs
page explains the intended migration, and the plan is written down in
docs/docs/explanation/ccg-edges.md:70-72 as three steps
starting with a script named
scripts/migrate_ccg_edges_to_native.py. That script does
not exist. Nor does a parser: the block is written by three
implementations and read by none. Until one lands, a NAMS-backed graph's
edges exist only as text, and the claims in README.md:152,
templates/base/README.md.j2:73 and
docs/docs/how-to/use-nams.md:87 that the frontend renders
them are wrong at this commit.
POST /cypher and the agent's Cypher tool on bolt
accept writes. The route's docstring says "On NAMS, only read
queries are accepted (server enforces)", and the bolt branch passes the
query to session.run with no access mode and no keyword
filter (routes.py.j2:274-292,
context_graph_client.py.j2:273-287). Searches for
execute_read, default_access_mode and a
write-keyword guard are in the appendix and return nothing. The endpoint
is CORS-exposed to a local frontend, so a DETACH DELETE
reaches a self-hosted store through the same door the graph view
uses.
The agent's tool reaches the same function under a description that says read-only. Imported Slack messages, Jira descriptions and Gmail bodies come back to the model as tool results, so an instruction planted in imported content has a write path to the graph on a self-hosted scaffold.
Trust is a float, and it is not consulted. A
correction decision carries confidence: 0.75, and the other
three detectors assign between 0.5 and 0.9
(decision_extractor.py:203, :295,
:372, :446). An explicit preference starts at
0.6 and climbs 0.15 per repeat to a 0.95 cap; a package-frequency
preference scores 0.4 plus 0.1 per session up to 0.9; anything under 0.4
is dropped at extraction (preference_extractor.py:112-121,
:154, :170). No read path consults either, and
no bundled tool's Cypher names confidence. The
Alternative node's wasChosen: False is the
nearest thing to an epistemic state and it describes a past decision,
not the status of a memory. trust_state is withheld; the
closest field in the whole tree is the data-journalism demo ontology's
Claim.verdict enum of true/mostly_true/…/unverified, which
is a schema for the demo's subject matter, with nothing
filtering on it.
Privacy. Claude Code redaction is well-wired — four
call sites covering message content, tool output, error text and
document bodies, and the generated connector template drops the toggle
entirely and redacts unconditionally
(templates/backend/connectors/claude_code_connector.py.j2:87-97).
The other twelve connectors have no equivalent: a Slack channel or a
Gmail thread is imported as-is. And the output lands in tracked paths —
the generated .gitignore ignores .env and
data/documents/*.json but not
data/fixtures.json or .context-graph/, so an
imported corpus and a deadletter carrying the records that failed to
write are committed by default.
Withheld marks, stated once.
tombstone— theREJECTEDedge and itsAlternativewithwasChosen: Falseand a reason is the near-miss, and a good one; it is keyed on a session-and-index hash rather than on the value, so re-extraction of the same rejected approach creates a new node and nothing consults the old one.trust_state— confidence floats only, and nothing filters on them.scope_enforced— the near-miss is nine REST read paths filtering the seededdomainkey, null-permissively; the agent's tools read the same graph with no predicate (section 5).bitemporal— novalid_from/valid_until/as_offield exists anywhere; searches in the appendix.audit_log— there is no mutation record in the system's own store. Thedeadletter.jsonlis append-only and durable but records writes that failed, which is the opposite object; decision traces record reasoning, not changes to memory.human_review— the frontend displays entities, documents and traces and cannot alter any of them; the only POSTs it issues are/chat/stream,/expandand one fixedMATCHthrough/cypher, and the backend has no PUT, PATCH or DELETE route at all.
Race conditions and recovery. Every ingest is sequential and idempotent by MERGE, so a re-run is safe; the watermark design makes a partial import re-fetchable. The counterpart is that a crash mid-ingest leaves a half-populated graph with no marker saying so, and on the hosted backend no way to clean it.
10. Tests, Evals, and Benchmarks
The suite is 1,125 test_ functions across 28 files, with
41 parametrize decorators. CI runs a test job
(pytest tests/ -v on Python 3.11 and 3.12) and a
lint job on every push and pull request, a
full-suite job adding --slow --functional, and
a smoke-test job that is main-push-only
and gated behind a SMOKE_TESTS_ENABLED repository
variable. I ran none of it.
The contract test is the best thing in the suite.
tests/test_nams_ingest_parity.py exists because
run_nams_ingest and the rendered
import_data.py are duplicated on purpose. It loads the
rendered template into a namespace and drives both against one canonical
fixture chosen to hit every branch: an entity with no body, one with a
body, one with outbound edges, a document, a trace. It captures every
client call from each and diffs the sequences, including splitting both
descriptions on the ccg-edges fence and comparing the
blocks (:354-376). It is the right instrument for the risk
it was built for.
The domain-scoping test cannot fail.
tests/test_security.py:165-186, parametrized over all
twenty-seven domains, collects every agent tool whose Cypher contains
$domain or domain: and then asserts
assert len(domain_scoped_tools) >= 0. A list's length is
never negative. The docstring and the failure message both describe a
check the assertion does not perform — and the check would fail if it
were written, because none of the 193 tool bodies in the bundled domains
contains either substring; the parse is in the appendix. The whole class
of "at minimum, the domain should have some tools with domain scoping"
is unverified at this commit for every domain, and the repair is one
character.
What backs the mark. negative_eval
rests on tests/test_memory_adapter.py:135-149, which is the
shape the mark asks for: three records in, PERSON and ORGANIZATION named
as excluded, and the kept DocA asserted by set equality, so
a function returning nothing fails. :176-196 adds a preview
exclusion with a positive control
(assert "chest pain" in result[0]["preview"]). Both run
unconditionally in the CI test job. Both hand
list_documents_nams a MagicMock client with
only long_term.search_entities set, so awaiting
client.query.cypher raises and the function takes its
search fallback; the Cypher branch the live service uses has no
behavioural test.
The scope-boundary case cannot fail on an empty
graph. tests/test_integration.py:309-334 seeds
financial-services and healthcare fixtures under two test domains and
asserts zero nodes in one domain carrying any of the seven labels unique
to the other. Nothing asserts those labels exist under the other domain.
The query is its own n.domain = $domain, not one of the
app's null-permissive read paths. It seeds through
ingest_data, and the CI job installs every extra from the
lockfile, so it takes the library branch at 0.0.5. Against the library's
python-v0.5.0 tag that branch writes no ontology label, and
every count would be zero whatever the isolation. It is also doubly
gated, and its neo4j_driver fixture calls
pytest.skip when Neo4j is unreachable.
Other sound coverage:
test_generated_client_runtime.py executes the rendered
client and memory modules against doubles rather than asserting on
template text, which is the distinction most template-testing suites
miss; test_security.py's parameterization checks catch
f-string interpolation of user input; test_doc_snippets.py
pins the ccg-edges documentation page's example against
_build_ccg_edges_block's actual output. A great deal of the
rest is string containment over rendered templates, which verifies that
a line was emitted and not that it works.
No benchmark, no eval harness, no paper.
scripts/e2e_smoke_test.py scaffolds, installs, starts a
backend and sends chat prompts, asserting the pipeline runs; it measures
nothing about retrieval quality and needs live API keys. Searching the
tree for arxiv, bibtex, @article,
@misc, doi.org and a CITATION
file finds nothing — there is no paper associated with this project, and
no committed result of any kind about whether the generated agent
answers better with the graph than without it.
11. For Your Own Build
Steal
- A watermark that advances only after the batch
lands. Keep the per-source cursor tentative through the run and
commit it only when nothing failed, so a partial import leaves the
window re-fetchable instead of silently skipped. Pair it with a JSONL
deadletter of individual failures and a
--retrythat renames the file aside before replaying it, so a failed retry cannot lose records. - A parity contract test over deliberately duplicated code. When two copies of a write path must exist, do not settle for reviewing both — drive both against one fixture, capture the call sequences, and diff them. The fixture is the design document: pick one case per load-bearing branch and say so in a comment.
- Refuse rather than report zero.
_reset_namscounts what it cannot delete and says the API does not exist, with the earlier swallow-the-AttributeError version named in the comment. A destructive operation that no-ops silently is worse than one that errors. - Redact at every content path, not at one chokepoint. Message bodies, tool output, error strings and document text all carry credentials, and a single call site covers whichever one you thought of first.
- A write-time marker that survives a server's type coercion. When a backend normalizes your types away, stamping your own convention into a field it preserves gives the read path something to filter on. Write the contract down where the reader can find it.
Avoid
- Encoding structure into a text field before the reader exists. The lossy write is defensible when the target API cannot hold the shape. Shipping it while the docs claim a parser renders it is not, and it is the failure mode this pattern always takes: the encoder is easy and gets written, the decoder is deferred, and the round trip is never closed. Write the reader first, or state in the README that the data is quarantined until one exists.
- Fetching context you then discard.
get_contextreturns four collections and the caller binds one. Either use the entities and preferences or stop paying for them — and if the design's headline is a knowledge graph the agent reasons over, notice when the code hands that graph to nobody. - An assertion that cannot fail, under a docstring that
describes one that could.
len(x) >= 0over a list is the purest form, but the tell is general: if the failure message names a condition the assertion does not evaluate, the test is documentation. - A permissive scope predicate.
n.domain IS NULL OR n.domain = $domainadmits every row your own writer did not stamp, which is precisely the rows another writer produced. - Creating an index nothing queries. A vector index over a property a dependency fills, at a dimension the default embedder does not produce, beside two uncalled search functions, reads from outside exactly like a working vector arm.
- A read-only promise made in a tool description. Telling the model a tool is read-only constrains nothing the model or an injected instruction writes. Open the session in read mode so the database refuses the write, and keep the free-form query tool off any surface that reads imported text.
Fit
This is a demo generator and it is an unusually good one — twenty-seven ontologies, eight frameworks, a 216-combination render matrix in CI, and a frontend that makes a graph legible in about five minutes. Take it if you want to show someone what an agent over a knowledge graph looks like, or if you want a running skeleton to gut.
Do not take the generated app as a memory architecture to build a
product on. It has no way to correct or delete a single memory on either
backend, and its self-hosted agent can rewrite the graph through a tool
it is told is read-only. Its default backend cannot store a relationship
in a form anything reads back, and its agent never sees the long-term
tier unless it writes the Cypher itself. The people best served here
will replace memory.py and
context_graph_client.py within a week and keep the ontology
pipeline, the connectors and the import machinery, which is the reusable
third of the repository.
12. Open Questions
- Whether the hosted service's own entity resolution creates edges
that make the missing
ccg-edgesparser matter less in practice —expand_node_namsqueries forSAME_ASedges the service creates, and only a live workspace shows how much of a graph that is. - What
client.long_term.search_entitiesactually ranks on server-side, which decides whether the generated app has a semantic retrieval arm at all. - Whether the
_pole_type: OBJECT_document marker returning OBJECT-typed domain entities is known and accepted, or whether the document browser is understood to be showing documents only. - How the library's
get_contextweighs a query against stored messages, since that is the one ranked retrieval the generated agent actually consumes. - What the CLI's library branch writes at neo4j-agent-memory 0.0.5, the version its lockfile resolves and the integration job installs, which has no tag in the library's repository.
Appendix: File Index
- CLI and configuration:
src/create_context_graph/cli.py,wizard.py,config.py. - Ontology and codegen:
src/create_context_graph/ontology.py,custom_domain.py,renderer.py,generator.py,name_pools.py,domains/*.yaml,fixtures/*.json. - Write path:
src/create_context_graph/ingest.py;templates/backend/connectors/import_data.py.j2;templates/backend/shared/generate_data.py.j2. - Connectors:
src/create_context_graph/connectors/—claude_code_connector.py,local_file_connector.py,linear_connector.py,google_workspace_connector.py,_claude_code/{parser,decision_extractor,preference_extractor,redactor}.py,_chat_import/,_local_file/. - Generated memory layer:
templates/backend/shared/memory.py.j2,memory_adapter.py.j2,context_graph_client.py.j2,vector_client.py.j2,gds_client.py.j2,config.py.j2,main.py.j2,routes.py.j2. - Generated agents:
templates/backend/agents/{pydanticai,claude_agent_sdk,strands,google_adk,openai_agents,langgraph,crewai,anthropic_tools}/agent.py.j2. - Generated frontend and MCP:
templates/frontend/components/,templates/base/mcp/claude_desktop_config.json.j2,templates/base/Makefile.j2,templates/base/gitignore.j2,templates/base/dot_env.j2. - Tests:
tests/test_nams_ingest_parity.py,test_bolt_ingest_parity.py,test_memory_adapter.py,test_ingest_nams.py,test_generated_client_runtime.py,test_security.py,test_integration.py,test_connectors.py,test_doc_snippets.py.
Searches recorded for the negative claims
grep -rn 'ccg-edges' src/create_context_graph/templates/frontend/ # 0: no parser in the generated UI
grep -rn -F 'CCG_EDGES_OPEN' --include='*.py' --include='*.j2' src/ # writers in 3 files; the only readers split and keep [0]
find . -name '*migrate*' -not -path './.git/*' # 0: the docs' migrate_ccg_edges_to_native.py does not exist
grep -rn -F '$domain' src/create_context_graph/domains/ # 0 across all 27 bundled domain YAMLs
python3 - <<'PY' # 193 tool bodies parsed by indentation, 0 containing '$domain' or 'domain:', the test's two substrings
import glob, re
tools, hits = 0, []
for p in sorted(glob.glob('src/create_context_graph/domains/*.yaml')):
lines, i = open(p).read().split('\n'), 0
while i < len(lines):
m = re.match(r'^(\s*)cypher:\s*(\|[-+]?|>[-+]?)?\s*(.*)$', lines[i])
if not m: i += 1; continue
tools += 1; ind, body, j = len(m.group(1)), [m.group(3)], i + 1
while j < len(lines) and (not lines[j].strip() or len(lines[j]) - len(lines[j].lstrip()) > ind):
body.append(lines[j]); j += 1
if '$domain' in '\n'.join(body) or 'domain:' in '\n'.join(body): hits.append(p)
i = j
print(tools, hits)
PY
grep -rniE 'valid_from|valid_until|as_of|observed_at|effective_' --include='*.py' --include='*.j2' --include='*.yaml' . | grep -v '^./docs/' # only effective_mcp_profile / a demo property named effective_date
grep -rniE 'tombstone|forget|do_not_store|denylist|supersede' --include='*.py' --include='*.j2' . | grep -v '^./docs/' # 0 for all five identifiers
grep -rn 'delete_entity\|remove_entity\|long_term.delete' --include='*.py' --include='*.j2' . | grep -v '^./docs/' # only the comment saying it does not exist, and the test asserting it is not called
grep -rniE 'execute_read|read_only|default_access_mode|ACCESS_MODE_READ' --include='*.py' --include='*.j2' . | grep -v '^./docs/' # 0: only Google OAuth *.readonly scopes
grep -rn 'vector_search(\|hybrid_search(' --include='*.py' --include='*.j2' --include='*.tsx' . # 0 callers outside the definitions
grep -rn "SET n.embedding\|\"embedding\":" --include='*.j2' --include='*.py' src/ # 2 hits, both query parameters in vector_client.py.j2: no writer in this tree
grep -rn 'context\.get(' src/create_context_graph/templates/backend/agents/ # 16 lines, all context.get("messages", [])
grep -n '@router.put\|@router.delete\|@router.patch' src/create_context_graph/templates/backend/shared/routes.py.j2 # 0: no mutating route, hence no review surface
grep -rniE 'arxiv|bibtex|@article|@misc|doi\.org' --include='*.md' --include='*.cff' --include='*.toml' . ; find . -iname 'CITATION*' # 0: no paper
grep -rniE 'detach|forbid|blocklist|denylist|_WRITE_|write_keyword|is_read_only' src/create_context_graph/templates/backend/shared/routes.py.j2 src/create_context_graph/templates/backend/shared/context_graph_client.py.j2 src/create_context_graph/templates/backend/agents/*/agent.py.j2 # 2: a docstring and reset_database's DETACH DELETE, no write guard
grep -rn 'read-only Cypher' src/create_context_graph/templates/backend/agents/ # 9 lines across all 8 templates
grep -rn 'domain IS NULL OR' src/create_context_graph/templates/ # 16: 9 in five client helpers, 6 in four route handlers, 1 in the seeder's MENTIONS link
grep -c -F '$domain' src/create_context_graph/renderer.py # 0: the Google Workspace and Claude Code connector tools carry no domain predicate either
grep -rn 'setdefault("domain"' src/create_context_graph/templates/backend/agents/ # 9: run_cypher in all 8 templates, plus the domain tools in claude_agent_sdk; no fixed body reads it
grep -n 'MERGE (n:\|MERGE (d:Document' src/create_context_graph/templates/backend/connectors/import_data.py.j2 # :485 on {name}, :547 on {title}: make import stamps no domain
grep -n 'query.cypher' tests/test_memory_adapter.py # 0: the document tests never configure the Cypher branch
grep -rn 'confidence' src/create_context_graph/domains/ | grep -vE 'name: confidence|_template|action:|Cover known|confidence scores' # 0: no tool Cypher names confidence
grep -rn 'method: "POST"' src/create_context_graph/templates/frontend/ # 3: /chat/stream, /cypher, /expand
grep -rn 'localStorage' src/create_context_graph/templates/ # 0: chat history and session id are both in sessionStorage
python3 - <<'PY' # 102 OBJECT pole types out of 170 entity types, from the committed domain YAMLs
import re, glob, collections
c = collections.Counter()
for p in glob.glob('src/create_context_graph/domains/*.yaml'):
t = open(p).read()
if 'entity_types:' not in t: continue
s = t.split('entity_types:', 1)[1].split('\nrelationship_types:', 1)[0]
for _, pt in re.findall(r'-\s*label:\s*(\S+)\s*\n\s*pole_type:\s*(\S+)', s): c[pt] += 1
print(sum(c.values()), c)
PY
History
2026-10-01 — 707b168d…
— audited at the same commit; scope_enforced withdrawn. The
mark rested on nine REST read paths, while the agent's domain tools run
fixed Cypher with no domain predicate through the same
execute_cypher against the same graph
(agents/pydanticai/agent.py.j2:63-76), and
run_cypher applies one only when the model writes it. An
agent-reachable read over the same store that cannot carry the predicate
withholds the mark; the routes become the near-miss (section 5). The connector tools
renderer.py appends were checked too and carry none. One
mark remains, negative_eval. Nothing installed, built or
run.
2026-09-26 — 707b168d…
— audit at an unchanged pin; upstream main has not moved.
The screen matches the first reading. Both marks hold on narrower
evidence. Corrected: the domain predicate sits on nine read paths, not
four, and neither the CLI's library ingest nor make import
stamps a node-level domain (section
5). The scope integration test has no positive control, and the
negative tests reach only the search fallback (section 10). The agent's
Cypher tool, described as read-only, writes on bolt, so "no tool that
writes memory" was wrong (section 8).
The library writes e.embedding, so the vector index is
unqueried rather than empty. Smaller fixes: ten correction patterns,
fourteen redaction patterns, 216 matrix combinations,
sessionStorage, a latent traces N+1. Nothing installed,
built or run.
2026-09-12 — 707b168d…
— first reading. Screened with scripts/screen_repo.py
before any file was opened: no auto-running configuration, two
build-time execution surfaces (Makefile,
tests/conftest.py, which runs at pytest collection), one
unpinned manifest (docs/package.json, fourteen floating
ranges with a lockfile beside it), uv.lock and
docs/package-lock.json both outside the seven-day cooldown,
and CLAUDE.md read as data. Nothing was installed, built or
run; the POLE-type distribution was recomputed from the committed domain
YAMLs in Python.