Custom endpoint from scratch
Walk through building a server-side endpoint that retrieves documents from a graph-scoped neighborhood, runs hybrid search, and returns a strongly-typed JSON response — written and deployed without leaving the workspace UI.
By the end you'll have a /api/endpoints/similar-products endpoint that takes a product key, returns the five most similar products on the same manufacturer, and is callable from any front-end or external service.
Estimated time: 20–30 minutes.
Where endpoints live
Custom endpoints run inside the workspace process, with full access to the graph, search, and AI runtime via an injected scope. You author them in Management → Endpoints; the workspace compiles and hot-loads each save.
The scope you write inside is documented in Endpoint execution scopes. Everything in the table there — Graph, Q(), Body, Headers, CurrentUser, ChatAI, Logger, Ok(), Forbid(), RelayStatusAsync(), RunEndpointAsync<T> — is available as a top-level identifier in your endpoint code.
Step 1 — create the endpoint
In the workspace UI:
- Open Management → Endpoints, click + New endpoint.
- Path:
similar-products. Slashes are allowed for hierarchy (catalog/similar-products). - Mode:
Sync— the response is fast. - Authorization:
Restricted— requires a valid user or endpoint token. - Read only:
true— we never mutate the graph.
The workspace creates the endpoint file and drops you into an editor.
Step 2 — define the request / response shape
Start with types. Putting them at the top makes the endpoint self-documenting and gives the C# compiler something to enforce against.
public record SimilarProductsRequest(string ProductKey, int Limit = 5);
public record ProductHit(string Key, string Title, double Score);
public record SimilarProductsResponse(string SourceKey, ProductHit[] Results);
Body.FromJson<T>() deserializes the incoming request; the return value of the endpoint is serialized as JSON.
Step 3 — implement graph-scoped retrieval
The flow:
- Look up the source product.
- Walk to its manufacturer, then back out to siblings (other products by the same manufacturer).
- Run a hybrid search over those siblings using the source product's description as the query text.
- Apply ACL filtering by calling
CreateSearchAsUserAsync(_, CurrentUser).
var req = Body.FromJson<SimilarProductsRequest>();
if (string.IsNullOrWhiteSpace(req.ProductKey))
return BadRequest("ProductKey is required.");
// 1. Resolve the source node.
var source = Q().StartAt("Product", req.ProductKey).FirstOrDefault();
if (source is null)
return NotFound($"No product with key '{req.ProductKey}'.");
// 2. Find sibling UIDs on the same manufacturer.
var siblingUIDs = Q().StartAt(source.UID)
.Out("MadeBy") // -> Manufacturer
.In("MadeBy") // -> all products by that manufacturer
.Where(p => p.Key != req.ProductKey)
.AsUIDEnumerable()
.ToArray();
if (siblingUIDs.Length == 0)
return Ok(new SimilarProductsResponse(req.ProductKey, Array.Empty<ProductHit>()));
// 3. Hybrid search constrained to those siblings, filtered by caller's ACL.
var search = SearchRequest.For(source["Description"].AsString());
search.BeforeTypesFacet = new HashSet<string> { "Product" };
search.TargetUIDs = siblingUIDs;
search.HybridSearch = true;
var query = await Graph.CreateSearchAsUserAsync(search, CurrentUser, CancellationToken);
// 4. Shape the response.
var hits = query.Take(req.Limit)
.EmitWithScores()
.Select(h => new ProductHit(
Key: h.Node["Key"].AsString(),
Title: h.Node["Title"].AsString(),
Score: h.Score))
.ToArray();
return Ok(new SimilarProductsResponse(req.ProductKey, hits));
A few things to call out:
- Use
nameof(Nodes.Product)if your schema is attribute-typed — that gives you compile-time safety on the type name. TargetUIDsis the lever that makes this graph-scoped retrieval rather than full-corpus search.CreateSearchAsUserAsyncapplies the caller's permissions; nothing leaks even if the sibling graph contains restricted items.
Step 4 — return the right HTTP shape
The scope provides constructors for the common HTTP responses. Use them — they set the right status code and content-type:
| Helper | Status | When to use |
|---|---|---|
Ok(value) |
200 | Successful response (auto-serializes to JSON). |
BadRequest("...") |
400 | Caller's input is malformed or missing. |
Unauthorized("...") |
401 | No valid identity at all. |
Forbid("...") |
403 | Authenticated, but not allowed. |
NotFound("...") |
404 | The target resource doesn't exist. |
StatusCode(n, body) |
n | Anything else; e.g. 429 or 503. |
Don't throw from an endpoint to signal "bad request" — the workspace will surface that as a 500. Use BadRequest instead.
Step 5 — test from the shell
Inside Management → Shell, call the endpoint through RunEndpointAsync:
return await RunEndpointAsync<SimilarProductsResponse>(
"similar-products",
new { ProductKey = "P-12345", Limit = 3 });
You'll see the typed response in the shell. From an external client:
curl -X POST https://workspace.example.com/api/endpoints/similar-products \
-H "Authorization: Bearer $CURIOSITY_ENDPOINTS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "ProductKey": "P-12345", "Limit": 3 }'
Expected payload:
{
"sourceKey": "P-12345",
"results": [
{ "key": "P-12702", "title": "Carbon-Fiber Bicycle Frame v2", "score": 0.86 },
{ "key": "P-11904", "title": "Aluminum Trail Frame", "score": 0.78 },
{ "key": "P-12500", "title": "Carbon Road Frame (Demo Stock)", "score": 0.72 }
]
}
Step 6 — instrument and trace
Use the scope's Logger, RelayStatusAsync, and Tracker to make the endpoint observable:
Logger.LogInformation("similar-products called for {Key} by user {UID}", req.ProductKey, CurrentUser);
await RelayStatusAsync("Computing siblings...");
using var span = Tracker.Start("hybrid-search");
// ... search code ...
span.Dispose();
RelayStatusAsync is what powers the live "thinking..." messages in the chat UI; status strings stream to the caller while the endpoint runs.
When to choose Pooling mode instead
The default Sync mode keeps the HTTP connection open while the endpoint runs. For anything that may exceed ~30 seconds (large LLM completions, multi-step orchestration) switch to Pooling mode:
- The first call returns
202 Acceptedwith a job UID. - The client polls
/api/jobs/{uid}until done. RelayStatusAsyncupdates show up in the job status.
Cross-links
- Creating endpoints (UI walkthrough)
- Endpoint execution scopes — every helper in the scope
- Security best practices for endpoints
- Calling endpoints from a front-end or external client
- Permission-aware search