Cookbook
All recipes
Tool-call provenance for an agentAgent memory

Agent memory

Search and retrieval

Data quality

Graph analytics

Geospatial

Cookbook / Agent memory · Advanced

Tool-call provenance for an agent

A memory agent reads and writes its graph through two tools. Every call it makes becomes a ToolCall node in that same graph, written by the same statement as the call itself and linked to every node the call wrote or returned. An audit can then ask which call wrote a fact, which calls have read it since, what the failed calls left behind, and what each tool costs.

The agent's memory is small: entities, facts about them, and decisions. The trace sits beside it, in the same graph:

Data model: the agent's memory, and the trace of every call beside itA Session HAS_CALL each of its ToolCall nodes, with a timestamp; a ToolCall records its sequence number, tool name, the user's ask, status, row count and any error. Each ToolCall TOUCHED the Entity, Fact and Decision nodes of the agent's memory that it returned, and facts and decisions are ABOUT entities.SessionidToolCallsessionseq · name · askstatus · rowserrorEntitykey · kindFacttextDecisiontextapprovalHAS_CALLtsTOUCHEDTOUCHEDTOUCHEDABOUTABOUT
Data model: the agent's memory, and the trace of every call beside itA Session HAS_CALL each of its ToolCall nodes, with a timestamp; a ToolCall records its sequence number, tool name, the user's ask, status, row count and any error. Each ToolCall TOUCHED the Entity, Fact and Decision nodes of the agent's memory that it returned, and facts and decisions are ABOUT entities.SessionidToolCallsessionseq · name · askstatus · rowserrorEntitykey · kindFacttextDecisiontextapprovalHAS_CALLtsTOUCHEDTOUCHEDTOUCHEDABOUTABOUT
Data model: the agent's memory, and the trace of every call beside it

ask is the user message that led to the call, status is committed, read, or rolled_back, and rows is how many rows the call returned to the agent. The agent has two tools, remember and recall. Each takes a Cypher statement and its arguments, both written by an LLM. The steps below replay the twelve calls of one session, s1, with the arguments the model sent. Two of those calls fail, on purpose.

Set up the schema

Two constraints keep the memory tidy: one node per entity key and one per fact text. The other four protect the trace:

  • call_key, a NODE KEY on (session, seq): every ToolCall has both, and no two share them. A retried call cannot be recorded twice, nor a call without a sequence number (a UNIQUE constraint would allow that).
  • has_call_ts and has_call_ts_nn: every HAS_CALL edge carries an integer ts (epoch milliseconds), so sorting by time is exact.
  • session_id: one Session node per id, the anchor every call hangs from.
Step 1
CREATE CONSTRAINT entity_key IF NOT EXISTS FOR (e:Entity) REQUIRE e.key IS UNIQUE;
CREATE CONSTRAINT fact_text IF NOT EXISTS FOR (f:Fact) REQUIRE f.text IS UNIQUE;
CREATE CONSTRAINT session_id IF NOT EXISTS FOR (s:Session) REQUIRE s.id IS UNIQUE;
CREATE CONSTRAINT call_key IF NOT EXISTS
  FOR (c:ToolCall) REQUIRE (c.session, c.seq) IS NODE KEY;
CREATE CONSTRAINT has_call_ts IF NOT EXISTS
  FOR ()-[h:HAS_CALL]-() REQUIRE h.ts IS :: INTEGER;
CREATE CONSTRAINT has_call_ts_nn IF NOT EXISTS
  FOR ()-[h:HAS_CALL]-() REQUIRE h.ts IS NOT NULL;
CREATE (:Session {id: 's1'})
Recorded result
(no rows) · nodes created: 1, properties set: 1, labels added: 1, constraints added: 6
Step 2
SHOW CONSTRAINTS YIELD name, type, labelsOrTypes, properties
RETURN name, type, labelsOrTypes AS on, properties
ORDER BY name
Recorded result
nametypeonproperties
"call_key""NODE_KEY"["ToolCall"]["session", "seq"]
"entity_key""NODE_PROPERTY_UNIQUENESS"["Entity"]["key"]
"fact_text""NODE_PROPERTY_UNIQUENESS"["Fact"]["text"]
"has_call_ts""RELATIONSHIP_PROPERTY_TYPE"["HAS_CALL"]["ts"]
"has_call_ts_nn""RELATIONSHIP_PROPERTY_EXISTENCE"["HAS_CALL"]["ts"]
"session_id""NODE_PROPERTY_UNIQUENESS"["Session"]["id"]

6 rows

Trace a call in the same statement

The first call is the model's remember for "We run a billing service." Its Cypher creates one entity. To trace it, the step wraps that Cypher in CALL () { … } and appends the tracer. Only the Cypher's RETURN is adapted: it returns answer, the row the agent sees, and touched, the nodes the call wrote or read. Then, in the same statement, the tracer:

  1. collects the answers and the touched nodes, so it runs once per call even when the call returns no rows;
  2. creates the ToolCall, hung from the session by a HAS_CALL edge with the call's timestamp;
  3. links it to each touched node: reduce flattens the lists and drops nulls (an OPTIONAL MATCH can return some), and MERGE makes one TOUCHED edge per node;
  4. hands the answers back to the agent.
One statement runs the tool call and writes its traceWhen the agent calls a tool with Cypher and arguments, one Cypher statement runs the tool's Cypher in a CALL subquery that returns the answers and the touched nodes. The same statement collects them, creates a ToolCall with its HAS_CALL timestamp, and merges a TOUCHED relationship to each node, committing whole or not at all. The agent then reads the answers.

One Cypher statement: commits whole, or not at all

Agent calls a tool: Cypher + arguments

CALL () { … }
the tool's Cypher
returns answer · touched

collect answers
and touched nodes

CREATE ToolCall
and HAS_CALL {ts}

MERGE TOUCHED
to each node

Agent reads the answers

One statement runs the tool call and writes its traceWhen the agent calls a tool with Cypher and arguments, one Cypher statement runs the tool's Cypher in a CALL subquery that returns the answers and the touched nodes. The same statement collects them, creates a ToolCall with its HAS_CALL timestamp, and merges a TOUCHED relationship to each node, committing whole or not at all. The agent then reads the answers.

One Cypher statement: commits whole, or not at all

Agent calls a tool: Cypher + arguments

CALL () { … }
the tool's Cypher
returns answer · touched

collect answers
and touched nodes

CREATE ToolCall
and HAS_CALL {ts}

MERGE TOUCHED
to each node

Agent reads the answers

One statement runs the tool call and writes its trace

Because the write and its audit row are one statement, they commit together or not at all. A ToolCall with status: 'committed' exists only if the write it describes committed. The call's parameters hold the tool arguments next to a call map, which is what the tracer knows about the call:

Step 3
CALL () {
  CREATE (e:Entity {key: $key, kind: $kind})
  RETURN {entity: e.key} AS answer, [e] AS touched
}
WITH collect(answer) AS answers, collect(touched) AS touched
MATCH (s:Session {id: $call.session})
CREATE (s)-[:HAS_CALL {ts: $call.ts}]->
       (c:ToolCall {session: $call.session, seq: $call.seq, name: $call.tool,
                    ask: $call.ask, status: 'committed', rows: size(answers)})
FOREACH (n IN reduce(ns = [], t IN touched | ns + [x IN t WHERE x IS NOT NULL]) |
         MERGE (c)-[:TOUCHED]->(n))
WITH answers
UNWIND answers AS answer
RETURN answer
Parameters
Recorded result
answer
{entity: "billing service"}

1 row · nodes created: 2, relationships created: 2, properties set: 9, labels added: 2

The counters show both halves: the entity, and the ToolCall with its two edges. Every other call has the same shape: only the Cypher inside CALL and the arguments change, and a read records status: 'read'.

Remember, then recall

"Orders live in Postgres." creates a fact about the orders service, and the service itself, which is new:

Step 4
CALL () {
  MERGE (e:Entity {key: $entity}) ON CREATE SET e.kind = $kind
  CREATE (f:Fact {text: $text})-[:ABOUT]->(e)
  RETURN {entity: e.key, fact: f.text} AS answer, [e, f] AS touched
}
WITH collect(answer) AS answers, collect(touched) AS touched
MATCH (s:Session {id: $call.session})
CREATE (s)-[:HAS_CALL {ts: $call.ts}]->
       (c:ToolCall {session: $call.session, seq: $call.seq, name: $call.tool,
                    ask: $call.ask, status: 'committed', rows: size(answers)})
FOREACH (n IN reduce(ns = [], t IN touched | ns + [x IN t WHERE x IS NOT NULL]) |
         MERGE (c)-[:TOUCHED]->(n))
WITH answers
UNWIND answers AS answer
RETURN answer
Parameters
Recorded result
answer
{entity: "orders service", fact: "Postgres for orders"}

1 row · nodes created: 3, relationships created: 4, properties set: 10, labels added: 3

The agent then asks about a service it has never heard of. The recall finds nothing, so the agent gets no rows, but the call is still recorded, with rows: 0 and no TOUCHED edges:

Step 5
CALL () {
  MATCH (e:Entity {key: $key})<-[:ABOUT]-(x:Fact|Decision)
  RETURN {kind: labels(x)[0], text: x.text} AS answer, [e, x] AS touched
  ORDER BY answer.kind, answer.text
}
WITH collect(answer) AS answers, collect(touched) AS touched
MATCH (s:Session {id: $call.session})
CREATE (s)-[:HAS_CALL {ts: $call.ts}]->
       (c:ToolCall {session: $call.session, seq: $call.seq, name: $call.tool,
                    ask: $call.ask, status: 'read', rows: size(answers)})
FOREACH (n IN reduce(ns = [], t IN touched | ns + [x IN t WHERE x IS NOT NULL]) |
         MERGE (c)-[:TOUCHED]->(n))
WITH answers
UNWIND answers AS answer
RETURN answer
Parameters
Recorded result
| answer |
|--------|
0 rows · nodes created: 1, relationships created: 1, properties set: 7, labels added: 1

The same recall for the orders service returns the fact, and the call touches both the fact and the service:

Step 6
CALL () {
  MATCH (e:Entity {key: $key})<-[:ABOUT]-(x:Fact|Decision)
  RETURN {kind: labels(x)[0], text: x.text} AS answer, [e, x] AS touched
  ORDER BY answer.kind, answer.text
}
WITH collect(answer) AS answers, collect(touched) AS touched
MATCH (s:Session {id: $call.session})
CREATE (s)-[:HAS_CALL {ts: $call.ts}]->
       (c:ToolCall {session: $call.session, seq: $call.seq, name: $call.tool,
                    ask: $call.ask, status: 'read', rows: size(answers)})
FOREACH (n IN reduce(ns = [], t IN touched | ns + [x IN t WHERE x IS NOT NULL]) |
         MERGE (c)-[:TOUCHED]->(n))
WITH answers
UNWIND answers AS answer
RETURN answer
Parameters
Recorded result
answer
{kind: "Fact", text: "Postgres for orders"}

1 row · nodes created: 1, relationships created: 3, properties set: 7, labels added: 1

Two calls that fail

Call 5 records a decision nobody has reviewed yet. The model computes the approval as 100 * $agree / $reviewers with zero reviewers. The two MERGEs for kafka and OrderPlaced run first, then the division fails:

Step 7
CALL () {
  MERGE (k:Entity {key: $topic}) ON CREATE SET k.kind = 'technology'
  MERGE (ev:Entity {key: $event}) ON CREATE SET ev.kind = 'event'
  WITH k, ev
  MATCH (o:Entity {key: $producer}), (b:Entity {key: $consumer})
  CREATE (d:Decision {text: $text, approval: 100 * $agree / $reviewers})
  CREATE (d)-[:ABOUT]->(o), (d)-[:ABOUT]->(b), (d)-[:ABOUT]->(k), (d)-[:ABOUT]->(ev)
  RETURN {decision: d.text, approval: d.approval} AS answer, [d, k, ev, o, b] AS touched
}
WITH collect(answer) AS answers, collect(touched) AS touched
MATCH (s:Session {id: $call.session})
CREATE (s)-[:HAS_CALL {ts: $call.ts}]->
       (c:ToolCall {session: $call.session, seq: $call.seq, name: $call.tool,
                    ask: $call.ask, status: 'committed', rows: size(answers)})
FOREACH (n IN reduce(ns = [], t IN touched | ns + [x IN t WHERE x IS NOT NULL]) |
         MERGE (c)-[:TOUCHED]->(n))
WITH answers
UNWIND answers AS answer
RETURN answer
Parameters
Expected error
error: type error: / by zero

A failed statement leaves nothing behind: not the two entities it merged, and not its audit row. The tracer catches the error and records the call on its own, as rolled_back, with the error text and no TOUCHED edges. The census in the same step shows the memory is as it was before call 5.

Step 8
MATCH (s:Session {id: $call.session})
CREATE (s)-[:HAS_CALL {ts: $call.ts}]->
       (c:ToolCall {session: $call.session, seq: $call.seq, name: $call.tool,
                    ask: $call.ask, status: 'rolled_back', rows: 0, error: $error})
RETURN c.seq AS seq, c.status AS status, COUNT { (:Entity) } AS entities,
       COUNT { (:Fact) } AS facts, COUNT { (:Decision) } AS decisions
Parameters
Recorded result
seqstatusentitiesfactsdecisions
5"rolled_back"210

1 row · nodes created: 1, relationships created: 1, properties set: 8, labels added: 1

Call 6 creates the billing service again. The entity_key uniqueness constraint refuses it, and the tracer records the call as rolled_back, as it did call 5. Both steps are collapsed below; the census is unchanged.

Step 9
Show the setup step
CALL () {
  CREATE (e:Entity {key: $key, kind: $kind})
  RETURN {entity: e.key} AS answer, [e] AS touched
}
WITH collect(answer) AS answers, collect(touched) AS touched
MATCH (s:Session {id: $call.session})
CREATE (s)-[:HAS_CALL {ts: $call.ts}]->
       (c:ToolCall {session: $call.session, seq: $call.seq, name: $call.tool,
                    ask: $call.ask, status: 'committed', rows: size(answers)})
FOREACH (n IN reduce(ns = [], t IN touched | ns + [x IN t WHERE x IS NOT NULL]) |
         MERGE (c)-[:TOUCHED]->(n))
WITH answers
UNWIND answers AS answer
RETURN answer
Parameters
Expected error
error: semantic error: ConstraintValidationFailed: `entity_key` uniqueness
Step 10
Show the setup step
MATCH (s:Session {id: $call.session})
CREATE (s)-[:HAS_CALL {ts: $call.ts}]->
       (c:ToolCall {session: $call.session, seq: $call.seq, name: $call.tool,
                    ask: $call.ask, status: 'rolled_back', rows: 0, error: $error})
RETURN c.seq AS seq, c.status AS status, COUNT { (:Entity) } AS entities,
       COUNT { (:Fact) } AS facts, COUNT { (:Decision) } AS decisions
Parameters
Recorded result
seqstatusentitiesfactsdecisions
6"rolled_back"210

1 row · nodes created: 1, relationships created: 1, properties set: 8, labels added: 1

The rest of the session

Call 7 sends the decision again, now with two of three reviewers in favour. It merges the two new entities, creates the decision, and touches five nodes. $agree and $reviewers are integers, so the approval is 66, not 66.67:

Step 11
CALL () {
  MERGE (k:Entity {key: $topic}) ON CREATE SET k.kind = 'technology'
  MERGE (ev:Entity {key: $event}) ON CREATE SET ev.kind = 'event'
  WITH k, ev
  MATCH (o:Entity {key: $producer}), (b:Entity {key: $consumer})
  CREATE (d:Decision {text: $text, approval: 100 * $agree / $reviewers})
  CREATE (d)-[:ABOUT]->(o), (d)-[:ABOUT]->(b), (d)-[:ABOUT]->(k), (d)-[:ABOUT]->(ev)
  RETURN {decision: d.text, approval: d.approval} AS answer, [d, k, ev, o, b] AS touched
}
WITH collect(answer) AS answers, collect(touched) AS touched
MATCH (s:Session {id: $call.session})
CREATE (s)-[:HAS_CALL {ts: $call.ts}]->
       (c:ToolCall {session: $call.session, seq: $call.seq, name: $call.tool,
                    ask: $call.ask, status: 'committed', rows: size(answers)})
FOREACH (n IN reduce(ns = [], t IN touched | ns + [x IN t WHERE x IS NOT NULL]) |
         MERGE (c)-[:TOUCHED]->(n))
WITH answers
UNWIND answers AS answer
RETURN answer
Parameters
Recorded result
answer
{approval: 66, decision: "Publish OrderPlaced events to Kafka"}

1 row · nodes created: 4, relationships created: 10, properties set: 13, labels added: 4

Five more recalls follow, traced the same way, as one collapsed step with a statement per call. A step's statements share its parameters, so each call's names carry its number ($call8, $key8). What each call returned:

  • Call 8, the orders service again: the Kafka decision and the Postgres fact.
  • Call 9, facts mentioning "Postgres": the one fact.
  • Call 10 walks from kafka through the decision to the other entities it is about, and returns what else is known about them: the Postgres fact. It touches only what it returns, not the decision or kafka.
  • Call 11, every service with what is known about each: three rows.
  • Call 12 dumps everything: the decision once per entity it is about, and the fact. A step records its last statement's result, so these are the rows.
Step 12
Show the setup step
CALL () {
  MATCH (e:Entity {key: $key8})<-[:ABOUT]-(x:Fact|Decision)
  RETURN {kind: labels(x)[0], text: x.text} AS answer, [e, x] AS touched
  ORDER BY answer.kind, answer.text
}
WITH collect(answer) AS answers, collect(touched) AS touched
MATCH (s:Session {id: $call8.session})
CREATE (s)-[:HAS_CALL {ts: $call8.ts}]->
       (c:ToolCall {session: $call8.session, seq: $call8.seq, name: $call8.tool,
                    ask: $call8.ask, status: 'read', rows: size(answers)})
FOREACH (n IN reduce(ns = [], t IN touched | ns + [x IN t WHERE x IS NOT NULL]) |
         MERGE (c)-[:TOUCHED]->(n))
WITH answers
UNWIND answers AS answer
RETURN answer;

CALL () {
  MATCH (f:Fact) WHERE f.text CONTAINS $term9
  MATCH (f)-[:ABOUT]->(e:Entity)
  RETURN {text: f.text, entity: e.key} AS answer, [f, e] AS touched
  ORDER BY answer.entity
}
WITH collect(answer) AS answers, collect(touched) AS touched
MATCH (s:Session {id: $call9.session})
CREATE (s)-[:HAS_CALL {ts: $call9.ts}]->
       (c:ToolCall {session: $call9.session, seq: $call9.seq, name: $call9.tool,
                    ask: $call9.ask, status: 'read', rows: size(answers)})
FOREACH (n IN reduce(ns = [], t IN touched | ns + [x IN t WHERE x IS NOT NULL]) |
         MERGE (c)-[:TOUCHED]->(n))
WITH answers
UNWIND answers AS answer
RETURN answer;

CALL () {
  MATCH (k:Entity {key: $key10})<-[:ABOUT]-(d:Decision)-[:ABOUT]->(o:Entity)
        <-[:ABOUT]-(y)
  WHERE o <> k AND y <> d
  RETURN DISTINCT {entity: o.key, kind: labels(y)[0], text: y.text} AS answer,
                  [o, y] AS touched
  ORDER BY answer.entity, answer.text
}
WITH collect(answer) AS answers, collect(touched) AS touched
MATCH (s:Session {id: $call10.session})
CREATE (s)-[:HAS_CALL {ts: $call10.ts}]->
       (c:ToolCall {session: $call10.session, seq: $call10.seq, name: $call10.tool,
                    ask: $call10.ask, status: 'read', rows: size(answers)})
FOREACH (n IN reduce(ns = [], t IN touched | ns + [x IN t WHERE x IS NOT NULL]) |
         MERGE (c)-[:TOUCHED]->(n))
WITH answers
UNWIND answers AS answer
RETURN answer;

CALL () {
  MATCH (e:Entity {kind: $kind11})
  OPTIONAL MATCH (e)<-[:ABOUT]-(x:Fact|Decision)
  RETURN {entity: e.key, kind: labels(x)[0], text: x.text} AS answer, [e, x] AS touched
  ORDER BY answer.entity, answer.text
}
WITH collect(answer) AS answers, collect(touched) AS touched
MATCH (s:Session {id: $call11.session})
CREATE (s)-[:HAS_CALL {ts: $call11.ts}]->
       (c:ToolCall {session: $call11.session, seq: $call11.seq, name: $call11.tool,
                    ask: $call11.ask, status: 'read', rows: size(answers)})
FOREACH (n IN reduce(ns = [], t IN touched | ns + [x IN t WHERE x IS NOT NULL]) |
         MERGE (c)-[:TOUCHED]->(n))
WITH answers
UNWIND answers AS answer
RETURN answer;

CALL () {
  MATCH (x:Fact|Decision)-[:ABOUT]->(e:Entity)
  RETURN {kind: labels(x)[0], text: x.text, entity: e.key} AS answer, [x, e] AS touched
  ORDER BY answer.kind, answer.text, answer.entity
}
WITH collect(answer) AS answers, collect(touched) AS touched
MATCH (s:Session {id: $call12.session})
CREATE (s)-[:HAS_CALL {ts: $call12.ts}]->
       (c:ToolCall {session: $call12.session, seq: $call12.seq, name: $call12.tool,
                    ask: $call12.ask, status: 'read', rows: size(answers)})
FOREACH (n IN reduce(ns = [], t IN touched | ns + [x IN t WHERE x IS NOT NULL]) |
         MERGE (c)-[:TOUCHED]->(n))
WITH answers
UNWIND answers AS answer
RETURN answer
Parameters
Recorded result
answer
{entity: "OrderPlaced", kind: "Decision", text: "Publish OrderPlaced events to Kafka"}
{entity: "billing service", kind: "Decision", text: "Publish OrderPlaced events to Kafka"}
{entity: "kafka", kind: "Decision", text: "Publish OrderPlaced events to Kafka"}
{entity: "orders service", kind: "Decision", text: "Publish OrderPlaced events to Kafka"}
{entity: "orders service", kind: "Fact", text: "Postgres for orders"}

5 rows · nodes created: 5, relationships created: 22, properties set: 35, labels added: 5

The trace refuses bad audit rows

A runtime that loses the acknowledgement of call 7 retries it: the same Cypher, the same arguments, the same (session, seq). The decision has no natural key, so nothing in the memory would stop a second copy. The audit row stops it. The NODE KEY rejects the second ToolCall, and because the write and the audit row are one statement, the second decision goes with it:

Step 13
CALL () {
  MERGE (k:Entity {key: $topic}) ON CREATE SET k.kind = 'technology'
  MERGE (ev:Entity {key: $event}) ON CREATE SET ev.kind = 'event'
  WITH k, ev
  MATCH (o:Entity {key: $producer}), (b:Entity {key: $consumer})
  CREATE (d:Decision {text: $text, approval: 100 * $agree / $reviewers})
  CREATE (d)-[:ABOUT]->(o), (d)-[:ABOUT]->(b), (d)-[:ABOUT]->(k), (d)-[:ABOUT]->(ev)
  RETURN {decision: d.text, approval: d.approval} AS answer, [d, k, ev, o, b] AS touched
}
WITH collect(answer) AS answers, collect(touched) AS touched
MATCH (s:Session {id: $call.session})
CREATE (s)-[:HAS_CALL {ts: $call.ts}]->
       (c:ToolCall {session: $call.session, seq: $call.seq, name: $call.tool,
                    ask: $call.ask, status: 'committed', rows: size(answers)})
FOREACH (n IN reduce(ns = [], t IN touched | ns + [x IN t WHERE x IS NOT NULL]) |
         MERGE (c)-[:TOUCHED]->(n))
WITH answers
UNWIND answers AS answer
RETURN answer
Parameters
Expected error
error: semantic error: ConstraintValidationFailed: `call_key` uniqueness

The other half of the NODE KEY: a ToolCall without a seq is refused too.

Step 14
MATCH (s:Session {id: 's1'})
CREATE (s)-[:HAS_CALL {ts: 1772442780000}]->
       (:ToolCall {session: 's1', name: 'recall', status: 'read', rows: 0})
Expected error
error: semantic error: ConstraintValidationFailed: `call_key` requires properties

And a timestamp that is not an integer, here a datetime:

Step 15
MATCH (s:Session {id: 's1'})
CREATE (s)-[:HAS_CALL {ts: datetime('2026-03-02T09:13:00Z')}]->
       (:ToolCall {session: 's1', seq: 13, name: 'recall', status: 'read', rows: 0})
Expected error
error: semantic error: ConstraintValidationFailed: `has_call_ts` property type

None of the three left anything behind: the audits below still count twelve calls, four entities, and one decision.

Audit: what happened in the session

From here on everything is a read. First, each tool's calls: the rows they returned to the agent, the nodes they touched, and how they ended.

Step 16
MATCH (c:ToolCall)
RETURN c.name AS tool, count(*) AS calls, sum(c.rows) AS rows,
       sum(COUNT { (c)-[:TOUCHED]->() }) AS touched,
       count(CASE c.status WHEN 'read' THEN 1 END) AS read,
       count(CASE c.status WHEN 'committed' THEN 1 END) AS committed,
       count(CASE c.status WHEN 'rolled_back' THEN 1 END) AS rolled_back
ORDER BY tool
Recorded result
toolcallsrowstouchedreadcommittedrolled_back
"recall"71319700
"remember"538032

2 rows

The rolled-back calls touched nothing:

Step 17
MATCH (c:ToolCall {status: 'rolled_back'})
RETURN c.seq AS seq, c.name AS tool, COUNT { (c)-[:TOUCHED]->() } AS touched,
       c.error AS error
ORDER BY seq
Recorded result
seqtooltouchederror
5"remember"0"type error: / by zero"
6"remember"0"semantic error: ConstraintValidationFailed: `entity_key` uniqueness"

2 rows

The latest calls of the session, newest first, with the labels each one touched. The anchor is one unique Session and ts is an integer, so ORDER BY h.ts DESC LIMIT $n is exact.

Step 18
MATCH (s:Session {id: $session})-[h:HAS_CALL]->(c:ToolCall)
RETURN c.seq AS seq, c.name AS tool, c.status AS status,
       toString(datetime.fromEpochMillis(h.ts)) AS at,
       COLLECT { MATCH (c)-[:TOUCHED]->(n)
                 RETURN labels(n)[0] AS label ORDER BY label } AS touched
ORDER BY h.ts DESC
LIMIT $n
Parameters
Recorded result
seqtoolstatusattouched
12"recall""read""2026-03-02T09:12:00Z"["Decision", "Entity", "Entity", "Entity", "Entity", "Fact"]
11"recall""read""2026-03-02T09:11:00Z"["Decision", "Entity", "Entity", "Fact"]
10"recall""read""2026-03-02T09:10:00Z"["Entity", "Fact"]
9"recall""read""2026-03-02T09:09:00Z"["Entity", "Fact"]
8"recall""read""2026-03-02T09:08:00Z"["Decision", "Entity", "Fact"]

5 rows

What call 7 touched, as a graph. Open the graph view:

Step 19
MATCH path = (:Session {id: 's1'})-[:HAS_CALL]->(c:ToolCall {session: 's1', seq: 7})
             -[:TOUCHED]->(n)
WITH path, n
ORDER BY labels(n)[0], coalesce(n.key, n.text)
RETURN path
Recorded result
// 5 rows
(n0:Session {id: "s1"})-[r18:HAS_CALL {ts: 1772442420000}]->(n17:ToolCall {ask: "Same decision as before: OrderPlaced events go to Kafka, billing consumes them. Two of three reviewers agreed.", name: "remember", rows: 1, seq: 7, session: "s1", status: "committed"})-[r19:TOUCHED]->(n16:Decision {approval: 66, text: "Publish OrderPlaced events to Kafka"})
(n0)-[r18]->(n17)-[r21:TOUCHED]->(n15:Entity {key: "OrderPlaced", kind: "event"})
(n0)-[r18]->(n17)-[r23:TOUCHED]->(n1:Entity {key: "billing service", kind: "service"})
(n0)-[r18]->(n17)-[r20:TOUCHED]->(n14:Entity {key: "kafka", kind: "technology"})
(n0)-[r18]->(n17)-[r22:TOUCHED]->(n3:Entity {key: "orders service", kind: "service"})

Audit: the lineage of one fact

Every call that touched the fact "Postgres for orders", in order. The fact sits at the centre of the graph view, with the calls around it:

Step 20
MATCH path = (:Session)-[:HAS_CALL]->(c:ToolCall)-[:TOUCHED]->(:Fact {text: $text})
WITH path, c
ORDER BY c.seq
RETURN path
Parameters
Recorded result
// 7 rows
(n0:Session {id: "s1"})-[r3:HAS_CALL {ts: 1772442120000}]->(n5:ToolCall {ask: "Orders live in Postgres.", name: "remember", rows: 1, seq: 2, session: "s1", status: "committed"})-[r5:TOUCHED]->(n4:Fact {text: "Postgres for orders"})
(n0)-[r7:HAS_CALL {ts: 1772442240000}]->(n7:ToolCall {ask: "Which database do orders use?", name: "recall", rows: 1, seq: 4, session: "s1", status: "read"})-[r9:TOUCHED]->(n4)
(n0)-[r24:HAS_CALL {ts: 1772442480000}]->(n18:ToolCall {ask: "What do we know about the orders service now?", name: "recall", rows: 2, seq: 8, session: "s1", status: "read"})-[r27:TOUCHED]->(n4)
(n0)-[r28:HAS_CALL {ts: 1772442540000}]->(n19:ToolCall {ask: "Where did anyone mention Postgres?", name: "recall", rows: 1, seq: 9, session: "s1", status: "read"})-[r29:TOUCHED]->(n4)
(n0)-[r31:HAS_CALL {ts: 1772442600000}]->(n20:ToolCall {ask: "What else do we know about the things the Kafka decision affects?", name: "recall", rows: 1, seq: 10, session: "s1", status: "read"})-[r33:TOUCHED]->(n4)
(n0)-[r34:HAS_CALL {ts: 1772442660000}]->(n21:ToolCall {ask: "List our services and what we know about each.", name: "recall", rows: 3, seq: 11, session: "s1", status: "read"})-[r38:TOUCHED]->(n4)
(n0)-[r39:HAS_CALL {ts: 1772442720000}]->(n22:ToolCall {ask: "Dump everything you remember.", name: "recall", rows: 5, seq: 12, session: "s1", status: "read"})-[r45:TOUCHED]->(n4)

The same answer as one row. No call can touch a node before it exists, so the first committed call to touch the fact is the one that wrote it. The reads after it are the ones that have seen the fact since.

Step 21
MATCH (f:Fact {text: $text})<-[:TOUCHED]-(w:ToolCall {status: 'committed'})
WITH f, w
ORDER BY w.seq
LIMIT 1
RETURN w.seq AS wrote, w.name AS tool, w.ask AS ask,
       COLLECT { MATCH (f)<-[:TOUCHED]-(r:ToolCall {status: 'read'})
                 WHERE r.seq > w.seq
                 RETURN r.seq AS seq ORDER BY seq } AS read_since
Parameters
Recorded result
wrotetoolaskread_since
2"remember""Orders live in Postgres."[4, 8, 9, 10, 11, 12]

1 row

Audit: every memory node

For each node in the memory: the committed calls that touched it, and how many reads have returned it.

Step 22
MATCH (n:Entity|Fact|Decision)
RETURN labels(n)[0] AS label, coalesce(n.key, n.text) AS name,
       COLLECT { MATCH (n)<-[:TOUCHED]-(c:ToolCall {status: 'committed'})
                 RETURN c.seq AS seq ORDER BY seq } AS committed,
       COUNT { (n)<-[:TOUCHED]-(:ToolCall {status: 'read'}) } AS reads
ORDER BY label, name
Recorded result
labelnamecommittedreads
"Decision""Publish OrderPlaced events to Kafka"[7]3
"Entity""OrderPlaced"[7]1
"Entity""billing service"[1, 7]2
"Entity""kafka"[7]1
"Entity""orders service"[2, 7]6
"Fact""Postgres for orders"[2]6

6 rows

TOUCHED does not say whether a call created a node or only matched it: the billing service shows [1, 7] because call 1 created it and call 7 linked the decision to it.

What the program adds

The companion program runs the same session from Python, with a tracer that wraps any Cypher the model sends. It classifies each statement as a read or a write with EXPLAIN, times reads under PROFILE (this page leaves timings out: they change on every run), runs writes in an explicit transaction that records the call with cursor.stats and rolls back on any error, and stores each call's Cypher and arguments on its ToolCall as JSON strings. It links TOUCHED edges by elementId, then reopens the file in a new process for the audit and exports the cost table to Polars through Arrow.

About the data

Dataset
12 scripted tool calls from one agent session, each with its user message, arguments, and a fixed timestamp, plus the two errors the tracer caught (3 KB in 10 small blocks)
Source
The scripted plan of the examples project's 20-tool-call-provenance (data/plan.json), the Cypher and arguments an LLM memory agent would send
License
Apache-2.0

Every data block is published beside this page as data/<name>.json, and the whole recipe as markdown.

More in Agent memory