Querying the Graph
The graph query interface is a fluent C# API for traversing and querying the knowledge graph. The full interface is available inside Custom Endpoints and the Shell via the Q() / Query() global helpers. A subset is available in Data Connectors via graph.QueryAsync().
For the complete method reference, see Graph Query Language. This page covers the most common patterns and how to read node properties.
Using the Query Interface
In Endpoints and the Shell
The Q() helper creates a new query. Chain methods to build the pipeline, then either return the result directly or materialize it to C# objects for further logic.
// Return results directly from an endpoint
return Q().StartAt(N.SupportCase.Type)
.SortByTimestamp(oldestFirst: false)
.Take(50)
.Emit("N");
// Iterate over results in code
foreach (var node in Q().StartAt(N.SupportCase.Type).AsEnumerable())
{
var summary = node.GetString(N.SupportCase.Summary);
}
// Count matching nodes
int total = Q().StartAt(N.SupportCase.Type)
.WhereString(N.SupportCase.Type, N.SupportCase.Status, "Open")
.Count();
In Data Connectors
Use graph.QueryAsync(), which accepts a lambda over the library IQuery (a subset) and returns a QueryResults object.
var response = await graph.QueryAsync(q =>
q.StartAt(nameof(Nodes.Device)).Take(10).Emit("N"));
var nodes = response.GetEmitted("N");
See Querying in Data Connectors for the full data connector query reference.
Returning Results: Shell vs. Endpoints
A query is built the same way in both contexts — the difference is how you turn the result set into output.
- In the Shell, you explore interactively. Reach for the
Emitfamily (Emit,EmitCount,EmitWithEdges,EmitSummary, …): it sends the matched nodes straight to the result panel, with no types to define and no mapping to write. - In Endpoints, you usually want a stable, typed JSON shape for the caller. Materialize the query with
AsEnumerable(),ToList(),First(), and friends, then map eachReadOnlyNodeinto arecordand return that.
EmitThe Emit family is the quickest way to inspect data while you work in the Query or Shell tab. The matched nodes are serialized to the result panel as-is.
// Most recent open cases, dumped to the result panel
Q().StartAt(N.SupportCase.Type)
.WhereString(N.SupportCase.Type, N.SupportCase.Status, "Open")
.SortByTimestamp(oldestFirst: false)
.Take(50)
.Emit("cases");
In an endpoint, materialize the same query and project each node into a record, so callers get a predictable JSON contract.
public record OpenCase(string Id, string Summary, DateTimeOffset Opened);
var cases = Q().StartAt(N.SupportCase.Type)
.WhereString(N.SupportCase.Type, N.SupportCase.Status, "Open")
.SortByTimestamp(oldestFirst: false)
.Take(50)
.AsEnumerable()
.Select(n => new OpenCase(
n.GetString(N.SupportCase.Id),
n.GetString(N.SupportCase.Summary),
n.GetTime(N.SupportCase.Time).ToDateTimeOffset()))
.ToList();
return Ok(cases);
Why records in endpoints
Emit serializes Curiosity's internal node shape, which changes as you add fields and is awkward to consume from a typed client. Mapping into a record keeps the HTTP response decoupled from the graph schema and gives front-end and connector code a stable type to deserialize into.
Emit family — Shell and direct responses
The Emit methods send results to the output without materializing them into C# objects. They are the default choice in the Shell, and can also be returned directly from an endpoint when the raw node shape is acceptable.
| Method | Description |
|---|---|
Emit(string name, params string[] fields) |
Return matched nodes, optionally restricting fields. |
EmitWithEdges(string name, params string[] fields) |
Return nodes with their edge data. |
EmitWithScores(string name, params string[] fields) |
Return nodes with their relevance scores. |
EmitCount(string name) |
Return only the count of matched nodes. |
EmitUIDs(string name) |
Return only node UIDs. |
EmitSummary(string name) |
Return a structural graph summary (useful in the shell). |
EmitNeighborsSummary(string name) |
Return a summary of edges from the current node set. |
Materialize to C# objects — endpoint logic
Use these when you need to work with results in code — filtering, projecting into a record, or computing a response. This is the usual path when writing an endpoint.
| Method | Return Type | Description |
|---|---|---|
AsEnumerable() |
IEnumerable<ReadOnlyNode> |
Lazy iteration without loading all nodes at once. |
AsEnumerableAsync(...) |
IAsyncEnumerable<ReadOnlyNode> |
Async lazy iteration; parallelizes reads. Accepts a CancellationToken. See Async Operations. |
AsUIDEnumerable() |
IEnumerable<UID128> |
UIDs only — no node content loaded. |
AsTypedUIDEnumerable() |
IEnumerable<TypedUID128> |
Typed UID + node type pairs. |
AsScoredUIDEnumerable() |
IEnumerable<ScoredUID> |
UIDs with relevance scores. |
First() / FirstOrDefault() |
ReadOnlyNode |
First node. First() throws when empty; FirstOrDefault() returns null. |
Single() / SingleOrDefault() |
ReadOnlyNode |
The one node. Both throw when more than one element; Single() also throws when empty. |
ToList() |
List<ReadOnlyNode> |
Materialize all nodes into a list. |
ToUIDList() |
List<UID128> |
All UIDs as a list. |
ToUIDSet() |
HashSet<UID128> |
All UIDs as a set. |
ToScoredUIDList() |
List<ScoredUID> |
All scored UIDs as a list. |
ToDictionary(keySelector, …) |
Dictionary<TKey, …> |
Index nodes by a key selector (optionally with a value selector). |
ToJsonAsync() |
Task<string> |
Serialize results to a JSON string. |
Count() |
int |
Total count. |
Any() |
bool |
True if any results exist. |
// Lazy iteration (efficient for large sets)
foreach (var node in Q().StartAt(N.SupportCase.Type).AsEnumerable())
{
// process each node
}
// Materialize to a list
var recentCases = Q().StartAt(N.SupportCase.Type)
.SortByTimestamp(oldestFirst: false)
.Take(20)
.ToList();
// Check existence
bool hasOpen = Q().StartWhereString(N.SupportCase.Type, N.SupportCase.Status, "Open").Any();
Accessing Node Properties
Nodes returned from AsEnumerable(), ToList(), or inside a Where() predicate expose typed accessor methods.
| Data Type | Accessor | List Accessor |
|---|---|---|
| String | GetString(key) |
GetStringList(key) |
| Integer | GetInt(key) |
GetIntList(key) |
| Boolean | GetBool(key) |
GetBoolList(key) |
| Float | GetFloat(key) |
GetFloatList(key) |
| Double | GetDouble(key) |
GetDoubleList(key) |
| Long | GetLong(key) |
GetLongList(key) |
| DateTime | GetTime(key) |
GetTimeList(key) |
| UID | GetUID128(key) |
GetUID128List(key) |
| Dynamic | node[key] |
— |
Every compiled node schema also carries a read-only Key property that returns whatever the schema named its key field, and node["Key"] reads the same value — so code handed a node of a type it doesn't know can still read the field that identifies it. It is an alias, not a field of its own: it is never written to the wire, and writing through it is refused exactly as writing the key field is. Key is consequently a reserved schema field name, so a new schema cannot declare a field called Key (a schema that already had one keeps compiling, and its own field answers to the name). See the schema reference for the full reserved list.
Use auto-generated constants (e.g. N.SupportCase.Content) instead of raw strings to avoid typos:
foreach (var node in Q().StartAt(N.SupportCase.Type).AsEnumerable())
{
var id = node.GetString(N.SupportCase.Id);
var summary = node.GetString(N.SupportCase.Summary);
var time = node.GetTime(N.SupportCase.Time);
}
Common Patterns
Pagination
class PageRequest { public int Page { get; set; } }
const int pageSize = 50;
var req = ParseBody<PageRequest>();
return Q().StartAt(N.Part.Type)
.Page(req.Page, pageSize) // shorthand for Skip(req.Page * pageSize).Take(pageSize)
.Emit();
Sampling a result set
TakeRandom(count) replaces the result set with up to count of its elements, drawn uniformly at random and returned in random order. Use it for a spot check over a large type, a sample for an evaluation set, or a "random pick" endpoint — anywhere Take would keep giving you the same first rows.
// Five arbitrary open cases, different on every call
return Q().StartWhereString(N.SupportCase.Type, N.SupportCase.Status, "Open")
.TakeRandom(5)
.Emit("N");
Four things to know before using it:
- It is eager, unlike
Take: it reads the whole result set to sample from it. The sampling itself is reservoir sampling, so only the sample is held in memory — it never materializes the result set — but it does pay one pass over it. - Access filtering is applied before the sample, so the sample is drawn from what the caller may see rather than being spent on nodes that are filtered out afterwards.
countof zero or less empties the query, and a result set smaller thancountis kept whole.- It is graph-side only — available where the full
IQueryis (endpoints, AI tools, scheduled tasks, the shell), and not in the data-connector query subset or the Python client.
Exporting without the graph columns
ToCsvAsync, ToJsonLinesAsync and ToWorkbookAsync write four columns of graph metadata next to each node's own fields: Link, UID, Type and EdgeCount. Chain WithoutGraphMetadata() when the consumer of the file wants the record and not the node it came from — a spreadsheet handed to someone outside the workspace, or an export of generated data where a link back to a node says nothing.
using var stream = new MemoryStream();
await Q().StartAt(N.SupportCase.Type)
.WithoutGraphMetadata()
.ToCsvAsync(stream, cancellationToken: CancellationToken);
What is left is each node's fields plus its timestamp. Anything parsing an existing export keeps working only if you leave the call off — adding it removes columns.
Filter by related entity
var deviceUID = Node.GetUID(N.Device.Type, "MacBook Air");
return Q().StartAt(N.SupportCase.Type)
.IsRelatedTo(deviceUID)
.SortByTimestamp(oldestFirst: false)
.Take(50)
.Emit("N");
Semantic similarity with graph filter
class Request { public string Query { get; set; } public string Manufacturer { get; set; } }
var req = ParseBody<Request>();
return (await Q().StartAtSimilarTextAsync(req.Query, nodeTypes: new[] { N.SupportCase.Type }, count: 500))
.IsRelatedTo(Node.GetUID(N.Manufacturer.Type, req.Manufacturer))
.EmitWithScores();
Set operations
// Union: combine two queries
var open = Q().StartWhereString(N.SupportCase.Type, N.SupportCase.Status, "Open");
var high = Q().StartWhereString(N.SupportCase.Type, N.SupportCase.Priority, "High");
return open.Union(high).Distinct().SortByTimestamp(oldestFirst: false).Emit("N");
Shell exploration
// Graph structure overview
return Q().EmitSummary();
// What edges does SupportCase have?
return Q().StartAt(N.SupportCase.Type).EmitNeighborsSummary();
// Analytics: count cases per device
return Q().StartAt(N.Device.Type).AsEnumerable()
.ToDictionary(
n => n.GetString(N.Device.Name),
n => Q().StartAt(n.UID).Out(N.SupportCase.Type).Count()
);
Next Steps
- Full method reference: Graph Query Language
- Exact signatures: Graph query language (API reference)
- Search integration: Search Configuration
- Data connector queries: Querying in Data Connectors