Curiosity

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.

flowchart LR Client -->|HTTPS POST| Workspace Workspace -->|invokes| Endpoint["Endpoint code<br/>(your C#)"] Endpoint -->|Q()| Graph[(Graph)] Endpoint -->|CreateSearchAsUser| Index[(Search index)] Endpoint -->|ChatAI.CompleteAsync| LLM[LLM provider] Endpoint -->|EndpointResponse| Client

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:

  1. Open Management → Endpoints, click + New endpoint.
  2. Path: similar-products. Slashes are allowed for hierarchy (catalog/similar-products).
  3. Mode: Sync — the response is fast.
  4. Authorization: Restricted — requires a valid user or endpoint token.
  5. 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:

  1. Look up the source product.
  2. Walk to its manufacturer, then back out to siblings (other products by the same manufacturer).
  3. Run a hybrid search over those siblings using the source product's description as the query text.
  4. 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.
  • TargetUIDs is the lever that makes this graph-scoped retrieval rather than full-corpus search.
  • CreateSearchAsUserAsync applies 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 Accepted with a job UID.
  • The client polls /api/jobs/{uid} until done.
  • RelayStatusAsync updates show up in the job status.
© 2026 Curiosity. All rights reserved.
Powered by Neko