
Skill
context-intelligence-graph-query
query context intelligence property graphs
Description
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.
SKILL.md
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 overview
To 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 DESC
The 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 nosource — 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_count
The 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, sparsely populated (~7% of sessions) 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_at
To 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 25
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 lineages
Session.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 chain
Two 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 12
Trap 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 DESC
Self-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 DESC
Trap 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 8
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 25
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 50
Two 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 50
Section 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 25
The 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:
jq 'keys' /tmp/ci-blobs/SESSION_ID/EVENT_KEY.json # structure first
jq '.messages[-1].content' /tmp/ci-blobs/SESSION_ID/EVENT_KEY.json
Rules: check for a ci-blob:// prefix before parsing as JSON; lifted properties
bypass blobs entirely; always bound before loading.
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 100
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 50
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 50
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 50
Skills 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 100
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 50
Size 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 projection
Agent-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 20
Excludingselfhere 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 payload
Use 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 sparsely populated (~7% of sessions) with no canonical per-session
source, so use it to read one session's directory, never to scope a population.)
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.
More skills from the amplifier-bundle-context-intelligence repository
View all 6 skillsblob-reading
extract fields from blob URIs
Jul 7Data EngineeringFile StorageMicrosoftcontext-intelligence-evaluation-methodology
design evaluation metrics for tool signals
Jul 7Data AnalysisEvalsMicrosoftStatisticscontext-intelligence-session-navigation
navigate and extract session data
Jul 7AgentsData AnalysisMicrosoftcontext-intelligence-session-reconstruction
reconstruct local Amplifier session files
Jul 3AmplifierMetadataMicrosoftObservabilityworkflow-pattern-analysis
analyze workflow success and failure patterns
Jul 7Data AnalysisDebuggingMicrosoftWorkflow Automation
More from Microsoft
View publisherplaywright-trace
inspect Playwright trace files
playwright
Apr 6DebuggingPlaywrightTestingrushstack-best-practices
manage Rush monorepos with best practices
rushstack
Apr 6EngineeringLocal DevelopmentMicrosoftProject Management +1azure-ai-agents-persistent-dotnet
build AI agents with Azure .NET SDK
skills
Jul 3.NETAgentsAzureLLMazure-ai-anomalydetector-java
build anomaly detection applications with Java
skills
May 13AnalyticsAzureData AnalysisJava +2azure-ai-contentsafety-java
build content moderation applications with Azure AI
skills
Jul 7AI InfrastructureAzureJavaSecurityazure-ai-contentsafety-py
detect harmful content with Azure AI Content Safety
skills
Jul 18AzureComplianceLLMMicrosoft +2