context-intelligence-graph-query
Skill5 starsChanged 5 days agoMITView on GitHub
Use when querying the context-intelligence property graph for session history, tool call traces, LLM iteration analysis, execution scale metrics, agent delegation trees, skill loading, and recipe orchestration. Covers all graph layers, cross-layer SOURCED_FROM joins, SST navigation, blob handling, and verified Cypher patterns.
In this repository12 other files it gives its agents.
- AGENTS.mdAGENTS.md
- context-intelligence-derived-metricsSkill
- context-intelligence-eval-designSkill
- context-intelligence-evaluation-methodologySkill
- context-intelligence-gdsSkill
- context-intelligence-hill-climbingSkill
- context-intelligence-server-data-opsSkill
- context-intelligence-session-navigationSkill
- context-intelligence-session-reconstructionSkill
- context-intelligence-tool-designSkill
- transcriptSkill
- workflow-pattern-analysisSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
18 rules. About 0 are common, 4 are distinctive, and 14 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈4
Distinctive · estimate
This author's own choices. Worth a look.
14
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 39
If the context-intelligence server is unavailable or not configured for this session, graph-based analysis cannot proceed here.
- Distinctive: line 64
Before writing analytical Cypher against an unfamiliar area of the graph, ask the server what it actually contains.
- Very specific: line 86
Data layer 1 — raw events: Every kernel event preserved as an :Event node (:ToolPreEvent, :LlmResponseEvent, :PromptSubmitEvent, …).
- Very specific: line 98
All layers coexist in one graph, bridged by SOURCED_FROM edges: every layer-2 entity points back to the layer-1 event(s) that produced it …
- Distinctive: line 256
Use it to enumerate individual delegations, not to measure tree depth.
- Very specific: line 329
Every *_at property (started_at, ended_at, occurred_at, …) and last_updated — on nodes and on the edges that carry occurred_at (HAS_EVENT, …
- Very specific: line 352
Every numeric usage value — dollars (cost_usd) and every token field (input_tokens, output_tokens, cache_read_input_tokens, …
- Very specific: line 562
Add a small LIMIT, and
- Very specific: line 597
When a payload exceeds the storage threshold, the server replaces data with a ci-blob://SESSION_ID/EVENT_KEY URI.
- Very specific: line 616
Rules: check for a ci-blob:// prefix before parsing as JSON.
- Very specific: line 619
Going further —
blob-reading.md, beside this file: When you actually have to resolve a blob (rather than avoid one), read the level-3 … - Very specific: line 691
Use tc.started_at directly — it equals ToolPreEvent.occurred_at on 100% of rows.
- Very specific: line 870
Use apoc.convert.fromJsonList(...) for arrays.
- Distinctive: line 879
Rule: Prefer lifted first-class properties when they exist.
- Very specific: line 904
Always toFloat() the nested value — it deserializes from JSON as a string, and a bare sum() over it returns HTTP 500, not a wrong number.
- Very specific: line 945
Add {workspace: $workspace} to the MATCH (e:LlmResponseEvent ...) anchor for a per-workspace variant of either query.
- Very specific: line 954
Use cache_read_input_tokens and cache_creation_input_tokens — both complete.
- Very specific: line 958
Never source tokens or cost from Iteration.usage_* (Trap 7) — last-write-wins corrupted, and Iteration has no cost field at all.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
When the graph server is not configured
If the context-intelligence server is unavailable or not configured for this
session, graph-based analysis cannot proceed here. Delegate immediately to
session-navigator for all session analysis, event lookup, and delegation
tree tracing — do not attempt Cypher queries against a server that isn't
reachable.
Context Intelligence Graph Query
This skill equips you to navigate and extract insights from the context-intelligence
property graph using the graph_query tool. The graph holds a complete record of
every Amplifier session — what happened, when, how things connect, and at what scale.
Read the live schema from the server; read the meaning from this skill. The server can always tell you the current shapes — labels, relationship types, property names. It CANNOT tell you what those shapes mean, where they lie about their meaning, or how to scope, join, and bound a query safely. This skill deliberately does not freeze a schema dictionary (it goes stale and misleads). It keeps the grammar: the scoping levers, the traps, the cross-layer joins, and the verified patterns.
Section 1 — Introspect the Live Schema First
Before writing analytical Cypher against an unfamiliar area of the graph, ask the server what it actually contains. These calls are cheap and always current.
CALL db.labels() // every node label present
CALL db.relationshipTypes() // every edge type present
CALL db.schema.nodeTypeProperties() // property names + types per label
CALL db.schema.visualization() // label/edge connectivity overviewTo learn the real properties on a specific label (names AND which are populated), sample a few live nodes rather than trusting any documented list:
MATCH (n:ToolCall) WITH n LIMIT 25
UNWIND keys(n) AS k
RETURN k AS property, count(*) AS present_on_n_nodes
ORDER BY present_on_n_nodes DESCThe three-layer shape (stable orientation, not a dictionary):
- Data layer 1 — raw events. Every kernel event preserved as an
:Eventnode (:ToolPreEvent,:LlmResponseEvent,:PromptSubmitEvent, …). Answers what happened and when.node_iduses a__(double-underscore) separator. - Data layer 2 — semantic entities. Events assembled into
Session,OrchestratorRun,Iteration,ContentBlock,ToolCall,Prompt, and more, connected by typed relationships. Answers what ran, how, at what scale.node_iduses a::(double-colon) separator (a few types —ToolCall, concept nodes — use a bare identifier). - Foundation layer — above the kernel. Delegation trees (
Delegation,Agent), skill loads (SkillLoad), recipe orchestration (RecipeRun,RecipeStep,Recipe).
All layers coexist in one graph, bridged by SOURCED_FROM edges: every layer-2
entity points back to the layer-1 event(s) that produced it (Section 4). Use
db.schema.nodeTypeProperties() to confirm exact property names before you rely on
them — the sections below cover only the meaning the server cannot self-describe.
Reading results & citing the source (required)
graph_query returns {"source": {"name", "url", "origin"}, "rows": [...]} on
success. Read your data from rows. ALWAYS report source.name (its origin is
source, destination, or env) in your answer — the user must know which
endpoint the outcome came from.
On failure, source is present only when an endpoint was actually chosen:
endpoint-level errors (connection_error, timeout, http_status,
decode_error, and input-validation errors like a missing/invalid query that
occur after selection) carry error.source — cite it. Selection/config
errors that occur before any endpoint is chosen (ambiguous_source_selection,
unknown_source, source_misconfigured, configuration_error) have no
source — there is no single endpoint to name, so report that selection failed
rather than inventing one.
To discover what you can reach, call graph_query with list_sources: true — it
returns the connectable set ({"connectable_set": [{name, url, origin}, ...]})
without running a query. To target a specific endpoint (a configured read source
OR a hook upload destination), pass source: "<name>".
Section 2 — Scoping (Mandatory, and Not Self-Describing)
Scoping is the single most error-prone part of querying this graph, and the server cannot tell you how its client scopes. Get this right first.
The workspace tool argument — NOT a Cypher param
The graph_query tool reads a top-level workspace argument. It injects that
value as $workspace into your Cypher. Do not try to set the workspace through
params: {"workspace": ...} — the tool does not read the workspace from params, and
a mis-set workspace silently scopes to a tiny "default" decoy partition instead of
the real one. If your counts look implausibly small (a handful of sessions where you
expected hundreds), you are almost certainly scoped to the decoy.
// Correct: reference $workspace; set the workspace via the tool's top-level
// `workspace` argument, not via params.
MATCH (s:Session {workspace: $workspace})
RETURN count(s) AS session_countThe two real scoping levers
Both live as first-class properties on nodes — confirm them by sampling:
created_by— who produced the data (present on every node; the data is typically single-user).workspace— where: a lifted, dash-slugified label/slug (e.g.-mnt-linuxdata-workspaces-...-dashboard-ui), reliable at ~100% coverage. It is a label, not the literal working directory: the true directory lives in the eventdatapayload asworking_dir, and the sameworking_dir(/home/user/project) can map to differentworkspaceslugs (myproject,proj,multi). Treatworkspaceas the reliable scoping slug; treatworking_diras a payload field, ~0% queryable in-graph (0 of 4,504 / 0 of 9,086 sessions — not a lifted Session property) with no canonical per-session source — read it via the APOC parse in Section 8, but do not scope by it. The/cypherrequest's ownworkspacescope is a second, orthogonal server-side filter layered on top.
Always anchor scope on the first MATCH so the workspace index is used:
// CORRECT — workspace on the anchor node
MATCH (s:Session {workspace: $workspace, node_id: $session_id})
-[:HAS_EXECUTION]->(run:OrchestratorRun)
RETURN run.orchestrator_name, run.started_at
// INCORRECT — workspace on a downstream node misses the index
MATCH (s:Session {node_id: $session_id})
-[:HAS_EXECUTION]->(run:OrchestratorRun {workspace: $workspace})
RETURN run.orchestrator_name, run.started_atTo discover which workspaces exist before scoping, aggregate across all of them (use sparingly — it skips the partition index):
MATCH (s:Session)
WHERE s.workspace <> ''
RETURN s.workspace, s.created_by, count(s) AS sessions
ORDER BY sessions DESC
LIMIT 25Resolving "my" — use whoami, don't guess
created_by scopes by who produced the data, but the graph never tells you
who "you" are. When a request is scoped to the acting user themselves —
"my sessions", "sessions I created", "what have I been working on" — call the
whoami tool for the relevant server before filtering on created_by:
whoami() # default endpoint
whoami(source="prod") # a specific configured endpointIt returns {"contributor_id": "<github-id-or-null>", "source": {...}}. Use
contributor_id as the value to match against created_by:
MATCH (s:Session {workspace: $workspace, created_by: $contributor_id})
RETURN count(s) AS my_sessionsNull case. A null contributor_id means auth is disabled (or otherwise
unknown) on that server — there is no acting-user identity to filter by. Do
not guess or fall back to some other field. Ask the user how they want to
scope the query instead (e.g. by name, by workspace, or "show everyone's").
Section 3 — Traps: Where the Shapes Lie About Their Meaning
These are the highest-value items in this skill. Each is a place where reading the schema literally leads you to a wrong answer that looks rigorous. The server will happily hand you the misleading shape; only domain knowledge corrects it.
Trap 1 — Delegation lineage: use FORKED + Session.parent_id, not the edge walk
The true delegation/fork lineage is carried by the FORKED edge together with
Session.parent_id. This is the authoritative source for delegation depth and
ancestry. Verified live: the fork lineage reaches max depth 3.
// Authoritative lineage depth via the FORKED edge.
// Bound the path (never bare `*` — Section 7): *1..20 is generous headroom over the
// observed max depth of 3, and the RETURN is aggregate-only (no rows materialized).
MATCH path = (root:Session {workspace: $workspace})-[:FORKED*1..20]->(leaf:Session)
WHERE root.parent_id IS NULL
RETURN max(length(path)) AS max_fork_depth, count(path) AS lineagesSession.parent_id lets you reconstruct the same chain by property when an edge walk
is inconvenient (each child names its parent):
MATCH (child:Session {workspace: $workspace})
WHERE child.parent_id IS NOT NULL
RETURN child.node_id, child.parent_id
ORDER BY child.node_id LIMIT 200 // bounded (Section 7); anchor on a node_id to walk one specific chainTwo known undercounts — do NOT trust either for depth:
HAS_SUBSESSIONis a single hop only. It materializes only the immediate parent→child link; each sub-session is ingested as its own root, so a multi-hop walk(:Session)-[:HAS_SUBSESSION*]->()collapses to depth 1. It is a one-level lookup, not the delegation tree.- The
Delegationproperty-join (sub_session_id → parent_session_id) undercounts beyond hop 1. It is more complete than theHAS_SUBSESSIONwalk (it finds a second hop), which is exactly why it is dangerous: it looks like the answer and reports depth 2, but it still misses the deeper lineage thatFORKED/parent_idreveals. Use it to enumerate individual delegations, not to measure tree depth.
Trap 2 — context_depth / context_scope are inheritance modes, NOT tree depth
On Delegation nodes, context_depth and context_scope describe how much parent
context a delegation inherited — an inheritance mode with values like none,
recent, all. The name invites reading it as nesting/tree depth. It is not.
Self-delegations are almost always none; regular delegations spread across
none / recent / all.
Trap 3 — Tool data lives on ToolCall, not ToolExecution
:ToolCall carries the tool data you want: tool_name, tool_input, started_at,
ended_at, result_success, result_error, result_output. :ToolExecution is
property-less — a dead end; do not route tool queries through it. ToolCall is the
correct, simple source (verified: tool_name and started_at populated on 100% of
ToolCall nodes in a real workspace).
MATCH (t:ToolCall {workspace: $workspace})
RETURN t.tool_name AS tool, count(*) AS n
ORDER BY n DESC LIMIT 12Trap 4 — self is a self-delegation marker, never a real agent
Self-delegation is recorded explicitly as Delegation.is_self_delegation = true
(equivalently the child Session.agent = 'self'). Never infer it by string-matching
tool_input or comparing names.
'self' is a marker, not an identity — it means "this session spawned a fresh
sub-context of itself," so self is never the real actor. Before any per-agent stat
(centrality, counts, durations), resolve it up the fork chain to the nearest ancestor
whose agent <> 'self'. That actor is often the root/main session (agent IS NULL —
the user's top-level session, not a named sub-agent); occasionally a named agent that
self-delegated. Empirically the chain is a single hop (no self→self→…).
// Resolve every self-session to the actor that actually spawned it
MATCH (s:Session {workspace: $workspace, agent: 'self'})
OPTIONAL MATCH pth = (anc:Session)-[:FORKED*1..20]->(s)
WHERE anc.agent IS NULL OR anc.agent <> 'self'
WITH s, anc, pth ORDER BY length(pth) ASC
WITH s, head(collect(coalesce(anc.agent, 'root/main session'))) AS actor
RETURN coalesce(actor, 'unresolved') AS real_actor, count(*) AS self_delegations
ORDER BY self_delegations DESCSelf-delegation is itself a first-class signal (an actor extending its own context —
continuation / context management): count it as self-delegation when that is the
question, and resolve it when you need the real actor. A raw self bucket in a
per-agent aggregate is a bug — it attributes real work to a non-existent agent.
Report the resolved breakdown as an explicit finding — do not bury it in a blended
stat. The resolve-self join above only pays off if its result reaches the answer as
its own stated fact. For any agent-level or self-delegation question, lead with the
resolved self→actor split as a standalone sentence — how many, and what percent, of
self-delegations resolve to the root/main session continuing itself (agent IS NULL)
versus to named agents — before any roll-up or combined figure. Computing the
number correctly but folding it into a mixed "X% of all delegations are root-launched"
statistic hides the very fact the question asked for. This is a general output-shape
principle: surface the load-bearing breakdown first, then add supporting context — state
it, don't just compute it.
// The plain self-delegation split (the signal itself)
MATCH (d:Delegation {workspace: $workspace})
RETURN d.is_self_delegation AS self_delegation, count(*) AS n
ORDER BY n DESCTrap 5 — Timestamps are ZONED DATETIME; durations are computed
Every *_at property (started_at, ended_at, occurred_at, …) and last_updated
— on nodes and on the edges that carry occurred_at (HAS_EVENT, HAS_SUBSESSION,
FORKED) — is stored as a native Neo4j ZONED DATETIME, not a string.
- ❌
WHERE s.started_at > '2026-05-01'— comparing to a string literal silently returns nothing (no error raised). - ✅
WHERE s.started_at > datetime('2026-05-01')— wrap every literal indatetime(). - ✅ Rolling windows:
WHERE s.started_at > datetime() - duration('P30D').
Durations are not stored — compute them with duration.between():
MATCH (t:ToolCall {workspace: $workspace})
WHERE t.started_at IS NOT NULL AND t.ended_at IS NOT NULL
WITH t, duration.between(t.started_at, t.ended_at) AS d
RETURN t.tool_name AS tool, d.seconds AS secs
ORDER BY secs DESC LIMIT 8Rule — one owner per numeric fact: never multi-count cost or tokens by over-reading events
This is a cross-cutting rule, not a single trap — Traps 6 and 8 below are both instances of it.
Every numeric usage value — dollars (cost_usd) and every token field (input_tokens,
output_tokens, cache_read_input_tokens, cache_creation_input_tokens) — is emitted once
per LLM turn and is owned by the :LlmResponseEvent node. The identical usage map is then
echoed onto other events of the same turn: ContentBlockEndEvent (streaming replays it ~2.33x
per response) and ProviderResponseEvent (a third, lower-layer copy).
Consequence: any aggregation that reads a set wider than the owning label — an unpinned
:Event / :SST_EVENT sweep, or a cross-layer join through ContentBlockEndEvent /
ProviderResponseEvent — sums the same dollars and tokens 2–3x over. This is over-reading the
events, not a rounding error. Measured inflation ~3.4x on the production graph (a true ~$45.5K
reads as a phantom ~$156K); reproduced on an isolated DTU at 2.1x–3.9x across fields.
The rule, stated generally: a numeric fact has exactly one owning event layer — aggregate
it only from that layer. For LLM cost and tokens, the owner is :LlmResponseEvent. Concretely:
always MATCH (e:LlmResponseEvent); never sum() a usage field across a broader label set, and
never reach the same figure through a cross-layer join. If a numeric value appears on more than one
label, those are copies, not addends — pick the owner. This applies identically to dollars and
tokens, and to any future numeric usage field added to the payload.
Trap 6 — cost_usd is a JSON string nested in the payload, not a lifted property
An instance of the Rule above — for cost, the owner is :LlmResponseEvent. The trap-specific
wrinkle here is typing, not scoping:
There is no top-level lifted cost property anywhere in the graph. The real value lives at
data.usage.cost_usd on :LlmResponseEvent, serialized as a JSON string — not a number, and
not a top-level field. A bare sum(e.cost_usd) or sum(e.data.usage.cost_usd) returns HTTP
500 (Section 6: data is a string, dot-access into it type-errors; the nested numeric value also
arrives as a JSON string, not a Cypher number, so it must be parsed and cast with toFloat()).
Validated (returns $44,886.32 over 63,758 events):
MATCH (e:LlmResponseEvent) WHERE e.data CONTAINS 'cost_usd'
WITH apoc.convert.fromJsonMap(e.data) AS d
RETURN sum(toFloat(d.usage.cost_usd)) AS total_cost_usdSee Section 8 for the full worked pattern.
Trap 7 — Iteration.node_id MERGEs across orchestrator runs
Iteration.node_id is composed as "{session_id}::iteration::{N}" — but N (iteration_number)
restarts at 1 for every OrchestratorRun, not once per session. Because the MERGE key omits
the run, one Iteration node absorbs iterations from every run that reused the same number —
observed up to 15 run parents on a single Iteration node.
This corrupts anything that aggregates through Iteration:
- Last-write-wins property garbage on
usage_input,usage_output,usage_cache_write, andmessage_count— whichever run's ingest landed last overwrites the others' values. count(DISTINCT Iteration)undercounts — iterations from different runs that share a number collapse into one node.run → Iteration → ToolCalltraversals overcount tool calls by +1.1% to +66% — aToolCallreached through the mergedIterationlooks like it belongs to every run that touches that node.
Every query pattern below that groups or counts through Iteration inherits this bug — see the
caveat noted at each: Section 4's SOURCED_FROM example, and Section 7's "Full conversation turn
trace," "Tool usage per iteration," "Failed tool calls," and "Skills active per iteration."
What to do instead:
- For tool-call attribution to a prompt or run, use the temporal join pattern in Section 7
— it never touches
Iterationand is unaffected by this bug. - For token or cost counts, never trust
Iteration.usage_*/message_count— recompute directly fromLlmResponseEventpayloads using the validated Trap 8 / Section 8 aggregation pattern (Iterationcarries no cost field at all, and its token counters are last-write-wins corrupted).
Trust marker: a bare node_id of the form "{session_id}::iteration::{N}" with no
orchestrator-run segment is pre-fix data — suspect. A node_id that additionally scopes to
the run (i.e. the run is part of the key, not just N) is post-fix — trustworthy. Check the
literal node_id shape before trusting an Iteration aggregate.
Trap 8 — Token & cache usage: payload integers, still owned by :LlmResponseEvent
An instance of the Rule above (before Trap 6) — for tokens, the owner is also
:LlmResponseEvent, exactly as for cost. Token and cache-usage figures live at
data.usage.{input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens}
on :LlmResponseEvent, parsed the same way as cost (Trap 6) via apoc.convert.fromJsonMap(e.data).
The trap-specific wrinkle is the opposite typing quirk:
- Typing is the opposite of
cost_usd. The token fields deserialize as INTEGER, not a JSON string —toInteger()on them is defensive/optional, not required.cost_usd(Trap 6) is the STRING one;toFloat()there is mandatory. Don't let the cost pattern's mandatory cast make you assume tokens need the same cast for the same reason — they don't need it to avoid an error, only to normalize type. Correct typing does not protect you from wrong scoping — validated inflation when the Rule is violated: cost 3.43x, input tokens 3.31x, output tokens 3.63x, cache tokens 3.39x. - Use the complete cache fields, not the sparse/wrong-typed ones.
cache_read_input_tokensandcache_creation_input_tokensare complete. Do not sumcache_read_tokens(sparse — NULL on many events) orcache_creation(a MAP, not a number — summing it type-errors or silently drops).cache_write_tokensis close tocache_creation_input_tokensbut diverges ~1.5%; prefercache_creation_input_tokensas the validated figure. - Never aggregate from
Iteration.Iteration.usage_input/usage_output/usage_cache_writeare the same Trap 7 last-write-wins corruption (undercounts 10–14%), andIterationcarries no cost field at all — there is noIteration-based cost figure to fall back to even approximately. Recompute both tokens and cost fromLlmResponseEventpayloads. - NULL and zero-cost caveat. ~4% of events have a null
cost_usd(sum()ignores nulls, so the total stays correct — just don't expectcount(*)to equal the events actually priced). Some models/providers are non-priced and report$0cost with real, non-zero token counts. Always reportllm_callsand the token columns alongside cost so this gap is visible rather than hidden inside a dollar figure.
See Section 8 for the validated combined cost+token aggregation and per-model breakdown.
Section 4 — Cross-Layer Joins (SOURCED_FROM)
Data layer 1 (raw events) and data layer 2 (semantic entities) coexist in one graph.
The canonical bridge is the SOURCED_FROM edge: every layer-2 entity points to the
layer-1 event(s) that produced it. Use layer 1 for exact raw fields/timeline; use
layer 2 for structure, scale, and causation; move between them with SOURCED_FROM.
// Semantic ToolCall → its originating raw event (canonical)
MATCH (s:Session {workspace: $workspace, node_id: $session_id})
-[:HAS_EXECUTION]->(:OrchestratorRun)
-[:HAS_PART]->(iter:Iteration)
-[:HAS_TOOL_CALL]->(tc:ToolCall)
MATCH (tc)-[:SOURCED_FROM]->(pre:ToolPreEvent)
RETURN iter.iteration_number AS iteration,
tc.tool_name AS tool,
tc.result_success AS succeeded,
pre.occurred_at AS event_fired_at
ORDER BY pre.occurred_at
LIMIT 25Caveat (Trap 7):
iter.iteration_numberhere can be a cross-run merged node (up to 15 run parents observed) — do not treat this join as an authoritative per-run tool-call count. For per-prompt/per-run attribution, prefer the temporal join pattern in Section 7.
Fallback joins (only when SOURCED_FROM is absent — older sessions ingested before
the handler existed; check with the query in Gotcha 5):
ToolCall direct match —
:ToolCall.node_idis the provider'stool_call_id, which:ToolPreEventalso lifts astool_call_id. Join on that shared key. Works forToolCallonly.MATCH (e:ToolPreEvent {workspace: $workspace, tool_call_id: $tool_call_id}) MATCH (tc:ToolCall {node_id: e.tool_call_id}) RETURN e.tool_name, e.occurred_at, tc.result_success, tc.result_output- Session containment — join through the shared
:Session: layer 1 attaches raw events viaHAS_EVENT; layer 2 attaches entities viaHAS_EXECUTION/HAS_PART. Correlate on a shared scalar (e.g.iteration_number).
Section 5 — SST Navigation (Reasoning by Semantic Type)
Layer-2 nodes carry an SST type label that classifies each node by its fundamental
character, letting you query across entity boundaries without knowing specific labels
in advance. Confirm the current partition with db.labels(); the meaning is:
:SST_EVENT— a temporal, bounded occurrence (Session, OrchestratorRun, Iteration, ContentBlock, ToolCall, Prompt, Delegation, SkillLoad, RecipeRun, …).:SST_THING— a persistent resource/artifact (e.g. MountPlan).:SST_CONCEPT— an abstract, reusable identity (Orchestrator, Agent, Recipe).
Layer-2 edges carry an sst_semantic property expressing the abstract relationship
independent of concrete edge type: CONTAINS (containment), LEADS_TO (causal/
sequential), EXPRESSES (attribution), NEAR (concurrency). This lets you query
causation or containment uniformly.
// All temporal events in the most recent session, mixed types sorted correctly
MATCH (s:Session {workspace: $workspace})
WITH s ORDER BY s.started_at DESC LIMIT 1
MATCH (s)-[:HAS_EXECUTION|HAS_PART*1..3]->(e:SST_EVENT)
RETURN labels(e) AS types,
coalesce(e.started_at, e.occurred_at) AS at
ORDER BY at
LIMIT 50Two temporal keys across
:SST_EVENT: span nodes (Iteration, ContentBlock, ToolCall, Session, OrchestratorRun) storestarted_at; occurrence nodes (Prompt, Cancellation, ContextCompaction) storeoccurred_at. When you sweep a mixed set, usecoalesce(e.started_at, e.occurred_at)in both projection andORDER BYso occurrence nodes are not silently null and sorted out of order.
Hierarchy traversal — reach the full Session → Run → Iteration → ContentBlock tree
with a bounded variable-length path. Bound it (*1..3) to prevent runaway fanout:
MATCH (s:Session {workspace: $workspace, node_id: $session_id})
-[:HAS_EXECUTION|HAS_PART*1..3]->(iter:Iteration)
-[:HAS_TOOL_CALL]->(tc:ToolCall)
RETURN tc.tool_name, tc.started_at, tc.result_success
ORDER BY tc.started_at
LIMIT 50Section 6 — Text Extraction: Bounded Always, Blobs Carefully
Large text fields are the fastest way to destroy your context window. One unbounded
p.prompt query returned 1.21M tokens and overflowed the context. Treat every large
text field with discipline.
Firm rule — never return full text of many rows
For any large text field (prompt, data, data.content, tool_input,
result_output), you MUST bound it — no exceptions:
- Add a small
LIMIT, and - Truncate with
substring(field, 0, 200), or return onlysize(field)to gauge scale first, or aggregate (count,collectof a truncated projection).
// WRONG — full prompt text over many rows → context overflow
MATCH (p:Prompt {workspace: $workspace}) RETURN p.prompt
// RIGHT — measure scale first
MATCH (p:Prompt {workspace: $workspace})
RETURN count(p) AS n, max(size(p.prompt)) AS longest_chars
// RIGHT — bounded preview when you do need text
MATCH (p:Prompt {workspace: $workspace})
WHERE p.session_id = $session_id
RETURN substring(p.prompt, 0, 200) AS prompt_preview
ORDER BY p.occurred_at
LIMIT 25The data field is a JSON string, not a Cypher map
:Event.data is a serialized JSON string. Dot notation (e.data.tool_name) does not
work in Cypher — it raises a type-mismatch error, not a silent null. Prefer lifted
properties (tool_name, tool_call_id, model, provider, …) when they exist; when you
need a raw field that is only in the payload, parse it in-graph with APOC — see the
one authoritative pattern "Lift payload fields in-graph with APOC" in Section 8. Always bounded.
Inline vs blob — know which you have. Some payloads live inline; some are offloaded.
For example, orchestrator steering text lives inline at
OrchestratorSteeringInjectedEvent.data.content (a JSON string — parse it, no blob
hop). Do not assume a ci-blob:// indirection where the text is actually inline.
ci-blob:// references and the /blobs endpoint
When a payload exceeds the storage threshold, the server replaces data with a
ci-blob://SESSION_ID/EVENT_KEY URI. Resolve it via the blob store — the /blobs
endpoint (GET {url}/blobs/{session_id}[/{key}]), or the blob_read tool which
returns a local file path, not content:
result = blob_read(uri="ci-blob://SESSION_ID/EVENT_KEY") # -> {"path": "...", "source": {name, url, origin}}Then extract only the fields you need with jq — never load a whole blob into
context. $BLOB_PATH below is the path value returned by blob_read; use it
verbatim rather than constructing a path, since the blob store lives under the OS
temp directory and differs by platform:
jq 'keys' "$BLOB_PATH" # structure first
jq '.messages[-1].content' "$BLOB_PATH"Rules: check for a ci-blob:// prefix before parsing as JSON; lifted properties
bypass blobs entirely; always bound before loading.
Going further — blob-reading.md, beside this file. When you actually have to
resolve a blob (rather than avoid one), read the level-3 reference
@context-intelligence:skills/context-intelligence-graph-query/blob-reading.md
with read_file. It carries the full contract: when NOT to resolve at all, the
four-step resolution, blob_read's success and failure shapes (including which
errors carry a source you must cite and which do not), the safe jq extraction
patterns, and blob-vs-inline field detection.
Section 7 — Verified Query Patterns
These are validated against the live layer-2/foundation schema. All reference
$workspace (set via the tool's workspace argument, Section 2) and are bounded.
Full conversation turn trace — prompt → run → iterations → tool calls:
MATCH (s:Session {workspace: $workspace, node_id: $session_id})
-[:HAS_PART]->(p:Prompt)
-[:TRIGGERS]->(run:OrchestratorRun)
-[:HAS_PART]->(iter:Iteration)
-[:HAS_TOOL_CALL]->(tc:ToolCall)
RETURN p.occurred_at AS turn_start, iter.iteration_number AS iteration,
tc.tool_name AS tool, tc.result_success AS succeeded
ORDER BY p.occurred_at, iter.iteration_number, tc.started_at
LIMIT 100Caveat (Trap 7):
iter.iteration_numberhere can be a merged node spanning multiple orchestrator runs — do not trust it as a pure per-run counter. For attributing tool calls to a specific prompt/run, prefer the temporal join pattern below.
Tool usage per iteration — how many tools each LLM round fired:
MATCH (s:Session {workspace: $workspace, node_id: $session_id})
-[:HAS_EXECUTION]->(:OrchestratorRun)
-[:HAS_PART]->(iter:Iteration)
-[:HAS_TOOL_CALL]->(tc:ToolCall)
RETURN iter.iteration_number AS iteration,
collect(tc.tool_name) AS tools, count(tc) AS tool_count
ORDER BY iter.iteration_number
LIMIT 50Caveat (Trap 7): counts and groupings by
iter.iteration_numberabove inherit theIteration-MERGE bug (one node can span up to 15 runs) — treat this as an approximate, not authoritative, per-iteration tool count.
Per-prompt (or per-run) tool-call attribution — temporal join, no edges (preferred over the
Iteration-based counts above; see Trap 7):
There is no direct edge from ToolCall to Prompt or OrchestratorRun. The reliable way to
attribute a tool call to the prompt (or run) that was active when it fired is a temporal join
on tc.started_at — this is more accurate than the structural run → Iteration → ToolCall
path above, which inflates counts by +4–20% due to the Trap 7 Iteration-MERGE bug and silently
drops prompts:
// Per-prompt tool-call attribution — temporal, no new edges.
// Swap (p:Prompt)/p.occurred_at for (p:OrchestratorRun)/p.started_at for per-run.
MATCH (tc:ToolCall {session_id:$sid}) WHERE tc.started_at IS NOT NULL
OPTIONAL MATCH (p:Prompt {session_id:$sid}) WHERE p.occurred_at <= tc.started_at
WITH tc, p ORDER BY p.occurred_at DESC
WITH tc, head(collect(p)) AS last_prompt
RETURN coalesce(last_prompt.node_id,'<orphan: no preceding prompt>') AS prompt_id,
count(tc) AS tool_calls
ORDER BY prompt_idCaveats:
- Use
tc.started_atdirectly — it equalsToolPreEvent.occurred_aton 100% of rows. Do not join viaSOURCED_FROMfor this: it fans out to bothtool:preandtool:postevents for the same call and double-counts. - Report an explicit orphan bucket (
<orphan: no preceding prompt>) rather than dropping those rows — ~0.2% of tool calls occur in sub-agent sessions with noPromptnode, and a few precede the session's first prompt. - Filter the ~0.007% of
ToolCallrows with a nullstarted_at(theWHEREabove already does this). - This is a single-writer timeline — no clock skew, no tied timestamps observed in validation.
Failed tool calls — scope to one session; add ORDER BY tc.started_at DESC for
most-recent-first:
MATCH (s:Session {workspace: $workspace, node_id: $session_id})
-[:HAS_EXECUTION]->(:OrchestratorRun)
-[:HAS_PART]->(iter:Iteration)
-[:HAS_TOOL_CALL]->(tc:ToolCall)
WHERE tc.result_success = false
RETURN iter.iteration_number AS iteration, tc.tool_name AS tool,
tc.result_error AS error, tc.started_at AS failed_at
ORDER BY tc.started_at
LIMIT 50Caveat (Trap 7):
iter.iteration_numbermay be a cross-run merged node — a failed call attributed to "iteration 3" may actually belong to iteration 3 of a different run. Cross-check against the temporal join pattern above when per-run precision matters.
Delegations in a session — which tool call triggered which agent (enumeration, not depth — see Trap 1 for lineage/depth):
MATCH (s:Session {workspace: $workspace, node_id: $session_id})
-[:HAS_EXECUTION]->(:OrchestratorRun)-[:HAS_PART]->(:Iteration)
-[:HAS_TOOL_CALL]->(tc:ToolCall)-[:TRIGGERED]->(d:Delegation)
RETURN d.agent, d.sub_session_id, d.is_self_delegation, d.context_depth,
d.started_at, tc.tool_name AS via_tool
ORDER BY d.started_at
LIMIT 50Skills active per iteration — note skills loaded before the first request attach to
Session, not Iteration (Gotcha 6):
MATCH (s:Session {workspace: $workspace, node_id: $session_id})
-[:HAS_EXECUTION]->(:OrchestratorRun)-[:HAS_PART]->(iter:Iteration)
-[:HAS_SKILL_LOAD]->(sl:SkillLoad)
RETURN iter.iteration_number, sl.skill_name, sl.content_length, sl.started_at
ORDER BY iter.iteration_number, sl.started_at
LIMIT 100Caveat (Trap 7):
iter.iteration_numbercan be a cross-run merged node — a skill load shown under "iteration 2" may actually have loaded during a different run's iteration 2.
Recipe run trace:
MATCH (s:Session {workspace: $workspace, node_id: $session_id})
-[:HAS_RECIPE_RUN]->(rr:RecipeRun)-[:HAS_STEP]->(step:RecipeStep)
OPTIONAL MATCH (step)-[:TRIGGERED]->(target)
RETURN rr.name, rr.status, step.name,
labels(target) AS triggered_type, target.node_id AS triggered_id
ORDER BY step.started_at
LIMIT 50Size discipline (the cardinal rule)
The graph can hold thousands of sessions. Every traversal query MUST have a LIMIT.
Work as a funnel:
- Count first.
RETURN count(...)costs almost nothing — run it before any wide query over an unknown population. - Anchor on a session (
node_id: $session_idorWHERE s.node_id IN [...]) before traversing; an un-anchoredMATCH (s:Session {workspace: $workspace})spans every session and multiplies rows. - Filter and aggregate in Cypher, not in the client — every unneeded row is wasted context.
- Bound variable-length paths (
*1..3), never bare*. - Paginate with
ORDER BY <stable key> SKIP n LIMIT mwhen you genuinely need more than one page.
Section 8 — Push Work to the Database (APOC / GDS)
Prefer one server-side computation over many client round-trips. When an analysis
needs a deep or variable-length traversal, path-finding, centrality/influence, community
structure, or a multi-step aggregation that would otherwise mean pulling rows to the
client and iterating, do the work inside the graph. It is faster and keeps the
protocol un-chatty — fewer tool calls, less context churn. Both plugins are installed and
run on the live target (Neo4j Community here — GDS works). Rule of thumb: if
answering the question the naive way means many round-trips or a large client-side pull,
push that computation into the database. Probe once, compute server-side, and still
bound the RETURNED result (count / aggregate / LIMIT).
Probe availability: CALL apoc.help('path'), RETURN gds.version() (or CALL gds.list()).
- APOC — in-graph helpers.
apoc.convert.fromJsonMap(e.data)parses adataJSON string into a map without a clientjqhop.apoc.path.expandConfig(...)runs configurable in-graph traversals — subject to the same 1-hop caveat asHAS_SUBSESSION(Trap 1): for delegation depth, expand overFORKED/parent_id. Still bound your output. GDS — real graph analytics in one pass. For centrality/influence, community/cluster detection, or pathfinding across many sessions, project once and run the algorithm server-side instead of reconstructing the graph client-side. Always
gds.graph.dropthe projection when done:CALL gds.graph.project('g', 'Session', 'FORKED') YIELD nodeCount, relationshipCount; CALL gds.pageRank.stream('g') // or gds.degree / gds.betweenness (influence) YIELD nodeId, score RETURN gds.util.asNode(nodeId).node_id AS session, score ORDER BY score DESC LIMIT 20; CALL gds.wcc.stream('g') // or gds.louvain (community structure) YIELD componentId RETURN componentId, count(*) AS size ORDER BY size DESC LIMIT 20; CALL gds.graph.drop('g') YIELD graphName; // always release the projectionAgent-level, not session-level — agents are a PROPERTY to roll up by, not a projectable graph. "Which agent is a hub" ≠ "which session is a hub": one session can be highly central while its agent is not.
Agentconcept nodes do exist (Section 1; Gotcha 4), but they carry only inboundHAS_AGENTedges from sessions — there is no Agent→Agent edge structure to project, so GDS centrality has nothing to traverse at the agent level. Do NOTgds.graph.project.cypher(...)an agent-level graph; it returns an empty projection and is a dead end. Instead project the labels that exist —Session(nodes) andFORKED(relationships) — run the algorithm over sessions, then roll the per-session scores up by theSession.agentproperty in theRETURN. For the named-agent-hub ranking, drop theselfmarker and the root/main session (agent IS NULL) — a "which named agent" question is about named agents:// 'g' is the Session / FORKED projection from above — agents are NOT projected; // agent-level results come from aggregating session scores by the s.agent PROPERTY. CALL gds.pageRank.stream('g') YIELD nodeId, score WITH gds.util.asNode(nodeId) AS s, score WHERE s.agent IS NOT NULL AND s.agent <> 'self' // named agents only (see Trap 4 for 'self') RETURN s.agent AS agent, round(sum(score), 2) AS agent_influence, count(*) AS sessions ORDER BY agent_influence DESC LIMIT 20Excluding
selfhere answers "which named agent is a hub" — it does not makeselfvanish from the story. If the question also touches self-delegation, resolveselfup theFORKEDchain (Trap 4) and state that root/main-vs-named breakdown as its own finding; don't let the exclusion above silently drop it.Skip GDS for a simple count or grouping that one plain Cypher statement already does — reach for it when the naive alternative is iterative client-side fetching.
Lift payload fields in-graph with APOC (the one authoritative pattern)
Problem. Event.data (and nested payloads) is a serialized JSON string, not a Cypher
map. Dot-access like e.data.working_dir fails loudly — Neo4j raises:
Neo.ClientError.Statement.TypeError: Type mismatch: expected a map but was String(...)You must parse the string before reading a field. (Do not confuse this with the
silent-null trap in Trap 5 — that one is comparing a zoned datetime to a string literal,
which returns null with no error. This APOC case is a loud error, a different failure mode.)
Pattern — parse in-graph, project only the scalar you need:
WITH apoc.convert.fromJsonMap(e.data) AS d
RETURN d.working_dir AS working_dir // one scalar, not the whole payloadUse apoc.convert.fromJsonList(...) for arrays. Never pull the full data string to the
client to parse with jq.
Two wins:
- Correctness — dot-access on the string errors out; parsing with APOC is the only way to read the real value.
- Leanness — the parse and the field-selection happen server-side; the payload never crosses the wire or enters context — same discipline as Section 6 ("never return full text").
Rule. Prefer lifted first-class properties when they exist; when a field is only in the
payload, lift it in-graph with APOC and return just the scalar. (working_dir is the worked
example here — proven, and the whole point of the pattern — but per Section 2, it is not
a scoping lever: it is ~0% queryable in-graph (0 of 4,504 / 0 of 9,086 sessions — not a lifted
Session property) with no canonical per-session source, so use it to read one session's
directory, never to scope a population.)
Aggregate a numeric JSON-string payload field: total cost (Trap 6)
The same parse-in-graph idiom extends past reading a scalar to aggregating a numeric value
buried inside a JSON string. cost_usd lives at data.usage.cost_usd on :LlmResponseEvent,
itself a string inside the parsed map — cast it with toFloat() before summing, and pin the
label to avoid the triple-count described in Trap 6:
MATCH (e:LlmResponseEvent) WHERE e.data CONTAINS 'cost_usd'
WITH apoc.convert.fromJsonMap(e.data) AS d
RETURN sum(toFloat(d.usage.cost_usd)) AS total_cost_usdRules:
- Pre-filter with
WHERE e.data CONTAINS 'cost_usd'before parsing — cheaper than parsing everyLlmResponseEventjust to find the field absent. - Pin the label to
:LlmResponseEvent— do not sweep:SST_EVENTor join throughContentBlockEndEvent/ProviderResponseEventfor the same figure (Trap 6). - Always
toFloat()the nested value — it deserializes from JSON as a string, and a baresum()over it returns HTTP 500, not a wrong number.
Aggregate token + cost usage from LlmResponseEvent payloads (Trap 8)
Token and cache-usage fields sit alongside cost_usd in the same data.usage map, but they
deserialize as INTEGER, not a JSON string — toInteger() is defensive here, not required the
way toFloat() is for cost_usd. The scoping danger is identical to Trap 6: an unpinned :Event
sweep also matches ContentBlockEndEvent and ProviderResponseEvent, each replaying the same
response-level usage map, and inflates every total by ~3.4x regardless of field type.
Validated combined cost + tokens (canonical — aggregate == independent reduce == hand-summed subset):
MATCH (e:LlmResponseEvent)
WHERE e.data CONTAINS 'input_tokens'
WITH apoc.convert.fromJsonMap(e.data) AS d
RETURN count(*) AS llm_calls,
sum(toFloat(d.usage.cost_usd)) AS total_cost_usd,
sum(toInteger(d.usage.input_tokens)) AS input_tokens,
sum(toInteger(d.usage.output_tokens)) AS output_tokens,
sum(toInteger(d.usage.cache_read_input_tokens)) AS cache_read_tokens,
sum(toInteger(d.usage.cache_creation_input_tokens)) AS cache_creation_tokensPer-model breakdown:
MATCH (e:LlmResponseEvent)
WHERE e.data CONTAINS 'input_tokens'
WITH apoc.convert.fromJsonMap(e.data) AS d
RETURN d.model AS model, d.provider AS provider, count(*) AS calls,
round(sum(toFloat(d.usage.cost_usd)), 2) AS cost_usd,
sum(toInteger(d.usage.input_tokens)) AS input_tokens,
sum(toInteger(d.usage.output_tokens)) AS output_tokens,
sum(toInteger(d.usage.cache_read_input_tokens)) AS cache_read,
sum(toInteger(d.usage.cache_creation_input_tokens)) AS cache_creation
ORDER BY cost_usd DESC LIMIT 15Add {workspace: $workspace} to the MATCH (e:LlmResponseEvent ...) anchor for a per-workspace
variant of either query.
Validated global scale (real corpus snapshot, reader's sanity check): ~68k LLM calls → ~$45.5K
cost, input ~4.50B tokens, output ~80.6M tokens, cache_read ~4.62B tokens, cache_creation ~8.05B
tokens. The same query with the :LlmResponseEvent pin removed reports ~$156K (~3.4x) — wrong,
for the exact reason given in Trap 8.
Rules:
- Use
cache_read_input_tokensandcache_creation_input_tokens— both complete. Do not sumcache_read_tokens(sparse/NULL on many events) orcache_creation(a MAP, not a number).cache_write_tokensis close tocache_creation_input_tokensbut diverges ~1.5% — prefercache_creation_input_tokens. - Never source tokens or cost from
Iteration.usage_*(Trap 7) — last-write-wins corrupted, andIterationhas no cost field at all. - Report
llm_callsalongside cost and tokens — ~4% of events have a nullcost_usd(sum()ignores nulls so the total is still correct), and some non-priced models report$0cost with real token counts. Surfacingllm_callsand the token columns makes that gap visible instead of hiding it inside a dollar figure.
Gotchas
- Layer-2 / foundation nodes only exist if handlers ran, or the feature was used.
Sessions ingested before a handler was deployed, or with no delegation/skills/recipes,
simply lack those nodes. Use
OPTIONAL MATCHwhen joining them against arbitrary sessions. result_success = falseis the error path;result_errorholds the message. A nullresult_successmeans the post/error event hasn't been processed — the call is in-flight or its handler didn't run.ENABLESedges are sparse. TheOrchestratorRun → next Promptedge exists only for multi-turn chains (N prompts ⇒ N−1 edges). Do not use its presence to judge clean session termination.- Concept nodes (
Agent,Recipe,Orchestrator) are shared across sessions, merged by name. Querying(a:Agent)without a session anchor spans everything — reach them through the session (HAS_AGENTfrom the sub-session,HAS_RECIPEfrom aRecipeRun). SOURCED_FROMmay be absent on older sessions. Detect and fall back to the Section 4 fallback joins:MATCH (n:SST_EVENT) WHERE NOT (n)-[:SOURCED_FROM]->() AND NOT n:Session RETURN labels(n), count(*)SkillLoadmay attach toSession, notIteration. Skills loaded before the first request have no active iteration. AddOPTIONAL MATCH (s)-[:HAS_SKILL_LOAD]->(sl:SkillLoad)to catch session-level loads.IncompleteSessionis a health marker, not a terminal label. It reachedsession:endwith no capturedsession:start/fork, carrieshas_terminal: false, and none of:RootSession/:SubSession/:ForkedSession. Exclude it from normal terminal-session queries withWHERE NOT s:IncompleteSession. A spike in its count signals upstream event loss.- The node MERGE key is
{node_id, workspace}. The same logical entity (e.g. an Orchestrator namedloop-streaming) exists as a separate node per workspace. A cross-workspace query returns one node per workspace — account for this when aggregating.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

