Skills
Chapter 1 of 41
Build and query Kusto graphs from natural language.
5 minutes · 1,056 words · 34 sections
Build transient and persistent graphs from tabular data using KQL graph operators. This skill translates natural language into the edges-first graph construction pattern and graph query operators.
Use this skill when the user:
make-graph)graph-match, graph-shortest-paths, graph-to-table, graph-mark-componentsNot a natural-language-to-KQL converter. The input should generally be a working KQL query whose results the user wants converted to a graph, plus a natural-language description of the desired graph structure. Basic NL source requests are supported only when they map directly to a known table with obvious columns. For general NL-to-KQL conversion, use a dedicated query-generation skill (available separately).
Complementary skills:
azure-kusto-irql – composable security query primitives that produce the tabular inputs for graphsazure-kusto-irql-graph – IRQL’s Lift_To_Graph JSON mapping system for richly-typed, icon-decorated graphs in Kusto ExplorerThe fundamental pattern for building graphs in Kusto:
1. Define your EDGES -> src --> dest, with relationship type/properties
2. Define your NODE LOOKUPS -> display names, types, properties for each node ID
3. Union edge types -> if you have multiple relationship types
4. Union node lookups -> if you have multiple node types
5. Call make-graph -> edges | make-graph Source --> Target with nodes on nodeIdThis is how to think in make-graph. Edges are the relationships you care about. Nodes are lookup tables that give those IDs a face – display names, types, properties.
make-graph – Build a graph from tablesEdges | make-graph SourceId --> TargetId with Nodes on NodeIdEdges: tabular source where each row is an edgeSourceId --> TargetId: columns containing source and target node IDswith Nodes on NodeId: optional node property table joined by IDwith Nodes1 on Id1, Nodes2 on Id2graph-match – Find patternsG | graph-match (a)-[e]->(b) where <constraints> project <output>Pattern notation:
| Element | Named | Anonymous |
|---|---|---|
| Node | (n) | () |
| Edge left->right | -[e]-> | --> |
| Edge right->left | <-[e]- | <-- |
| Any direction | -[e]- | -- |
| Variable length | -[e*1..5]-> | -[*1..5]-> |
Multi-hop patterns: (a)-[e1]->(b)-[e2]->(c)
Star patterns: (a)--(center)--(b), (c)--(center)--(d)
Cycles control: cycles = all | none | unique_edges (default: unique_edges)
graph-shortest-paths – Find shortest pathsG | graph-shortest-paths (start)-[e*1..20]->(end)
where start.name == "Alice" and end.name == "Server01"
project Path = e, Length = array_length(e)output = any (default, one path per pair) or output = all (all equal-length shortest paths)graph-to-table – Export graph to tablesG | graph-to-table nodes // export nodes
G | graph-to-table edges // export edges
G | graph-to-table nodes as N, edges as E // export both
G | graph-to-table nodes with_node_id=Id // include node hash ID
G | graph-to-table edges with_source_id=Src with_target_id=Tgt // include edge endpoint IDsgraph-mark-components – Find connected componentsG | graph-mark-components with_component_id=ComponentId
| graph-to-table nodes
| summarize Members = make_list(name) by ComponentIdAssigns a ComponentId to each node. Nodes in the same connected component share the same ID.
graph() function – Query persistent graphsgraph("MyGraphModel") // latest snapshot
graph("MyGraphModel", "Snapshot_2025_01") // specific snapshot
graph("MyGraphModel", true) // transient from model definitionCreated dynamically during query execution. No setup required. Ideal for ad-hoc analysis, exploration, and prototyping.
// 1. Define edges
let edges = <SourceTable>
| summarize <aggregations> by SourceCol, TargetCol;
// 2. Define node lookups
let source_nodes = edges
| distinct SourceCol
| project nodeId = SourceCol, label = SourceCol, nodeType = "<SourceType>";
let target_nodes = edges
| distinct TargetCol
| project nodeId = TargetCol, label = TargetCol, nodeType = "<TargetType>";
let all_nodes = union source_nodes, target_nodes;
// 3. Build and query the graph
edges
| make-graph SourceCol --> TargetCol with all_nodes on nodeId
| graph-match (s)-[e]->(t)
where <constraints>
project Source = s.label, Target = t.label, <edge properties>// Multiple edge types -> union them with a common schema
let auth_edges = AuthEvents
| project Source = username, Target = hostname, edgeType = "authenticates", ts = timestamp;
let net_edges = NetworkEvents
| project Source = src_ip, Target = url, edgeType = "connects", ts = timestamp;
let all_edges = union auth_edges, net_edges;
// Node lookups from all sources
let user_nodes = Employees | project nodeId = username, label = name, nodeType = "User";
let host_nodes = AuthEvents | distinct hostname | project nodeId = hostname, label = hostname, nodeType = "Host";
let all_nodes = union user_nodes, host_nodes;
all_edges
| make-graph Source --> Target with all_nodes on nodeIdFor large-scale, reusable graphs. Stored in database metadata. Support snapshots for historical comparison.
Safety: Creating or altering graph models and snapshots modifies the database. Always show the exact command and confirm with the user before executing
.create-or-alter graph_modelor.make graph_snapshot.
.create-or-alter graph_model SecurityGraph
{
"Schema": {
"Nodes": {
"User": {"name": "string", "role": "string"},
"Host": {"hostname": "string"},
"IP": {"ip": "string"}
},
"Edges": {
"AuthenticatesTo": {"timestamp": "datetime", "result": "string"},
"ConnectsFrom": {"timestamp": "datetime"}
}
},
"Definition": {
"Steps": [
{
"Kind": "AddNodes",
"Query": "Employees | project name, role",
"NodeIdColumn": "name",
"Labels": ["User"]
},
{
"Kind": "AddNodes",
"Query": "AuthenticationEvents | distinct hostname | project hostname",
"NodeIdColumn": "hostname",
"Labels": ["Host"]
},
{
"Kind": "AddEdges",
"Query": "AuthenticationEvents | project username, hostname, timestamp, result",
"SourceColumn": "username",
"TargetColumn": "hostname",
"Labels": ["AuthenticatesTo"]
}
]
}
}.make graph_snapshot SecurityGraph Snapshot_2025_07graph("SecurityGraph")
| graph-match (user)-[auth]->(host)
where user.role == "Admin" and auth.result == "Failed Login"
project User = user.name, Host = host.hostname, Time = auth.timestampSafety: All control commands below modify or delete database objects. Never execute
.drop,.create-or-alter graph_model, or.make graph_snapshotautomatically. Always show the exact command, cluster, database, and affected object, then require explicit user confirmation before execution.
.show graph_models // list all models
.show graph_model SecurityGraph // show model details
.show graph_snapshots SecurityGraph // list snapshots
.drop graph_snapshot SecurityGraph Snapshot_2025_07 // delete a snapshot (CONFIRM FIRST)
.drop graph_model SecurityGraph // delete model and all snapshots (CONFIRM FIRST)| Factor | Transient (make-graph) | Persistent (graph()) |
|---|---|---|
| Setup | None – inline in query | Create model + snapshot |
| Lifetime | Query execution only | Stored in database metadata |
| Data freshness | Always current | Snapshot at creation time |
| Scale | Limited by query memory | Enterprise-scale |
| Reuse | Rebuilt every query | Shared across users/queries |
| Best for | Ad-hoc hunts, prototyping | Production workflows, dashboards |
let auth_edges = AuthenticationEvents
| summarize
logins = count(),
fails = countif(result == "Failed Login")
by src_ip, username, hostname;
let ip_nodes = auth_edges | distinct src_ip
| project nodeId = src_ip, label = src_ip, nodeType = "IP";
let user_nodes = auth_edges | distinct username
| project nodeId = username, label = username, nodeType = "User";
let host_nodes = auth_edges | distinct hostname
| project nodeId = hostname, label = hostname, nodeType = "Host";
let all_nodes = union ip_nodes, user_nodes, host_nodes;
// IP -> User edges
let ip_user = auth_edges
| project Source = src_ip, Target = username, logins, fails;
// User -> Host edges
let user_host = auth_edges
| project Source = username, Target = hostname, logins, fails;
union ip_user, user_host
| make-graph Source --> Target with all_nodes on nodeId
| graph-match (ip)-[e1]->(user)-[e2]->(host)
where e2.fails > 20
project
IP = ip.label,
User = user.label,
Host = host.label,
Failures = e2.fails
| order by Failures desc// Pattern: (user1)-[auth1]->(host)<-[auth2]-(user2)
// Two users both failing on the same host = possible credential spray
let edges = AuthenticationEvents
| summarize fails = countif(result == "Failed Login"), logins = count()
by username, hostname;
let nodes = union
(edges | distinct username | project nodeId = username, nodeType = "User"),
(edges | distinct hostname | project nodeId = hostname, nodeType = "Host");
edges
| make-graph username --> hostname with nodes on nodeId
| graph-match (u1)-[e1]->(h)<-[e2]-(u2)
where u1.nodeId != u2.nodeId and e1.fails > 10 and e2.fails > 10
project
User1 = u1.nodeId, User2 = u2.nodeId,
SharedHost = h.nodeId,
User1Fails = e1.fails, User2Fails = e2.fails
| distinct User1, SharedHost, User2, User1Fails, User2Fails
| order by User1Fails + User2Fails desclet edges = SecurityEvents
| project Source = source_entity, Target = target_entity, action, timestamp;
let nodes = union
(edges | distinct Source | project nodeId = Source),
(edges | distinct Target | project nodeId = Target);
edges
| make-graph Source --> Target with nodes on nodeId
| graph-shortest-paths (start)-[e*1..10]->(end)
where start.nodeId == "ExternalIP_1.2.3.4" and end.nodeId == "DatabaseServer"
project
PathLength = array_length(e),
Actions = e.action,
Hops = e.Targetlet edges = NetworkFlows
| project Source = src_ip, Target = dst_ip;
let nodes = union
(edges | distinct Source | project nodeId = Source),
(edges | distinct Target | project nodeId = Target);
edges
| make-graph Source --> Target with nodes on nodeId
| graph-mark-components with_component_id = ComponentId
| graph-to-table nodes
| summarize Members = make_list(nodeId), Size = count() by ComponentId
| order by Size descEnd a query at make-graph (without piping to graph-match) to trigger Kusto Explorer’s interactive graph visualization window:
edges
| make-graph Source --> Target with all_nodes on nodeId
// <- stop here. Kusto Explorer renders the graph visually.To flatten back to a table for dashboards or export, pipe through graph-match | project or graph-to-table.
When working with security data, consider using IRQL selectors (Get_*) from the azure-kusto-irql skill as the data source. IRQL gives you a unified schema without memorizing raw table names or column mappings. For rich visualization with icons and node folding, the azure-kusto-irql-graph skill’s Lift_To_Graph is the faster path.
| Approach | Best For |
|---|---|
Raw make-graph (this skill) | Full control, persistent models, shortest paths, connected components, custom schemas |
Lift_To_Graph (azure-kusto-irql-graph) | Quick icon-decorated visualization in Kusto Explorer, node folding |
IRQL Get_* -> make-graph | IRQL’s unified schema as input, then raw graph operators for analysis |
IRQL Get_* -> Lift_To_Graph -> Graph_Render_View | Fastest path from question to visual graph |
Note:
Lift_To_Graph,Graph_Render_View, andGraph_Fold_By_Propertyare stored functions, not built-in operators. They are pre-deployed on the kc7001 example cluster but may need deployment on other clusters. Seeazure-kusto-irql-graph/references/DEPLOY_IRQL_FUNCTIONS.mdfor function definitions and deployment instructions.
IRQL handles the data retrieval; make-graph handles the graph analysis. This finds the shortest path from an external IP to a mail server through auth events:
// IRQL provides unified columns (ClientIp, Hostname, Username, Result)
let auth = Get_Event_Authentication_All
| where Result == "Failed Login";
let edges = auth
| summarize Failures = count() by ClientIp, Hostname;
let nodes = union
(edges | distinct ClientIp | project nodeId = ClientIp, nodeType = "IP"),
(edges | distinct Hostname | project nodeId = Hostname, nodeType = "Host");
edges
| make-graph ClientIp --> Hostname with nodes on nodeId
| graph-shortest-paths (src)-[e*1..5]->(dest)
where src.nodeType == "IP" and dest.nodeId == "MAIL-SERVER01"
project
SourceIP = src.nodeId,
PathLength = array_length(e),
Hops = e.HostnameFind clusters of IPs and domains that are interconnected – potential C2 infrastructure:
let dns = Get_Dns_All;
let edges = dns | project Source = ClientIp, Target = Domain;
let nodes = union
(edges | distinct Source | project nodeId = Source, nodeType = "IP"),
(edges | distinct Target | project nodeId = Target, nodeType = "Domain");
edges
| make-graph Source --> Target with nodes on nodeId
| graph-mark-components with_component_id = ComponentId
| graph-to-table nodes
| summarize
IPs = make_set_if(nodeId, nodeType == "IP"),
Domains = make_set_if(nodeId, nodeType == "Domain"),
Size = count()
by ComponentId
| where Size > 3
| order by Size descSee references/EXAMPLES.md (opens in a new tab) for multi-source investigation graphs combining IRQL selectors with make-graph, and Lift_To_Graph visual graph examples.
See references/SCENARIOS.md (opens in a new tab) for full worked examples including:
| Tool | Purpose |
|---|---|
kusto_query | Execute KQL queries including make-graph, graph-match, and management commands |
kusto_table_schema_get | Discover table columns before building edge/node projections |
kusto_cluster_list | List available ADX clusters |
kusto_database_list | List databases in a cluster |
Optional convenience feature. The default workflow is to output the KQL in chat and let the user copy it into Kusto Explorer or the VS Code Kusto extension manually. Auto-launch is opt-in only.
Always output the complete KQL with Step 1 (connect) and Step 2 (query) clearly labeled:
// Step 1: Connect to your cluster (skip if already connected)
// Example: uncomment to connect to the KC7 training cluster
// #connect cluster('kc7001.eastus.kusto.windows.net').database('ValdyTimes')
// Or replace with your own cluster:
// #connect cluster('<YOUR_CLUSTER>').database('<YOUR_DATABASE>')
// Step 2: Run the query below
<KQL_QUERY ending at make-graph>Then immediately below, output an ADX Web Explorer version that appends | graph-to-table nodes as N, edges as E since ADX Web Explorer cannot render make-graph directly:
// ADX Web Explorer version (tabular output):
<SAME_QUERY>
| graph-to-table nodes as N, edges as EThis ensures the output works in both Kusto Explorer (graph visualization) and ADX Web Explorer (tabular results) without the user having to modify anything.
If the user asks to save or open the query in Kusto Explorer, follow the procedure in references/KUSTO_EXPLORER_LAUNCH.md (opens in a new tab). Key rules:
ask_user to confirm before writing files or launching executablesSet-Content/Add-Content.kql file and suggest the VS Code Kusto extension or ADX Web ExplorerFor make-graph visualization (the graph window), the query must end at make-graph — do not pipe to graph-match. Kusto Explorer only opens the graph visualization window when the output is a graph object, not a table.
Install this repository
npx skills add microsoft/azure-skills/plugin marketplace add microsoft/azure-skillsSkills install per repository, not per chapter — the CLI has no documented per-skill form, so we do not print one.
Build and query Kusto graphs from natural language. Covers transient graphs (make-graph), persistent graph models/snapshots, pattern matching (graph-match), shortest paths, connected components, and graph-to-table export. Generates the edges-first thinking: define edges, define node lookups, union, make-graph. WHEN: make-graph, graph-match, graph-shortest-paths, graph-to-table, graph-mark-components, persistent graph, graph model, graph snapshot, build a graph from data, find paths between nodes, pattern matching in graph, connected components, transient graph, Kusto graph, KQL graph.
The verbatim description from this skill’s front matter — the string an agent matches on to decide whether to load it.
main, last pushed 10 August 2026.SKILL.md, not by matching a directory convention. 7 distinct layouts observed: .github/plugins/azure-kusto-graph-skills/skills/*/SKILL.md, skills/*/SKILL.md, skills/azure-app-onboard/*/SKILL.md, skills/azure-kubernetes/*/SKILL.md, skills/microsoft-foundry/*/SKILL.md, skills/microsoft-foundry/models/deploy-model/*/SKILL.md, skills/microsoft-foundry/models/*/SKILL.md.h1 and no skipped levels:.claude-plugin/marketplace.json by Microsoft, declaring 1 plugin. It is read for editorial metadata only — never as the skill index, which is always the repository tree./microsoft/azure-skills.md, and each chapter at its own .md URL.3 files · 9 KB
Everything this skill ships beside its prose. All of it is set here, as subchapters of chapter 1.
Documentation the agent loads on demand, rather than up front.