Memory that ships as a template

Create Context Graph

A Neo4j Labs CLI that scaffolds an agent application around a three-tier context graph, with thirteen connectors importing SaaS and session history into it.

LicenceApache-2.0
Size16,962 lines of Python in 48 files and 13,421 lines of Jinja2 templates in 65 files under src/create_context_graph
Activity242 commits on main by 11 author names, one of them a bot, 21 March – 4 September 2026
Tests1,125 pytest functions in 28 files (19,839 lines), 41 parametrized

Carries 1 of 7 rubric mechanisms. Most systems here carry none or one (43%), and a dash means the mechanism was not found at this commit — not that the system needed it. Each mark is one LLM reviewer's reading of the code at this commit rather than a run of it — known limits.

  • Tombstone
  • Trust state
  • Bi-temporal
  • Scope enforced
  • Mutation audit
  • Human review
  • Negative evals

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 — 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
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 of already-active, activated, created or unavailable. Stage 1 writes entities: _serialize_entity_to_description (:84-115) renders every property outside {name, description, domain, id, uuid} as **Key**: value and appends _pole_type: X_; _description_with_edges (:180-189) appends the ccg-edges block; add_entity takes the result. An entity whose connector declares BODY_FIELDS also goes through short_term.add_message as 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, calls add_entity with attributes={**item, "domain": domain_id} (:609-615), MERGEs each relationship as a native edge behind _require_safe_cypher_identifier (:126-130), MERGEs :Document nodes and links MENTIONS by 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 whenever neo4j_agent_memory imports, which the optional ingest extra 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.0 tag (8bd50eff…), add_entity MERGEs (e:Entity {name: $name, type: $type}), adds only the POLE type as a label, and serializes attributes, domain included, into one e.metadata string (memory/long_term.py:504-525, graph/query_builder.py:270-291 there). The ontology label and a domain node property never reach an entity written this way. The CLI's uv.lock resolves 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 seed runs generate_data.py, which MERGEs every entity on {name, domain} with ontology labels (templates/backend/shared/generate_data.py.j2:68-73). make import runs _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 no domain on either.
  • Runtime write. Every framework's handle_message calls resolve_session_id, store_message(session_id, "user", message), get_context, the agent, then store_message(..., "assistant", ...) — templates/backend/agents/pydanticai/agent.py.j2:106-146 and 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 to client.query.cypher on 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-) and expand_node (:419-432) are the domain-filtered helpers behind the REST routes, and list_documents, get_document, list_traces and get_entity_detail in routes.py.j2 (:327-440) filter the same way; memory_adapter.py.j2 holds 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 only if not any_failure, and _retry_deadletter (:777-) which renames the file to .jsonl.retrying before 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) and preference_extractor.py (213 lines, fifteen explicit patterns plus package-frequency inference with _MIN_PACKAGE_SESSIONS = 2 and _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 at claude_code_connector.py:252, :306, :382 and :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, --integration only).

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 — the REJECTED edge and its Alternative with wasChosen: False and 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 seeded domain key, null-permissively; the agent's tools read the same graph with no predicate (section 5).
  • bitemporal — no valid_from/valid_until/as_of field exists anywhere; searches in the appendix.
  • audit_log — there is no mutation record in the system's own store. The deadletter.jsonl is 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, /expand and one fixed MATCH through /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 --retry that 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_nams counts 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_context returns 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) >= 0 over 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 = $domain admits 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-edges parser matter less in practice — expand_node_nams queries for SAME_AS edges the service creates, and only a live workspace shows how much of a graph that is.
  • What client.long_term.search_entities actually 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_context weighs 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.