Curiosity

Debugging Endpoints

Endpoints run hot — the workspace recompiles on save — which makes the inner loop fast. These tips keep the loop tight when the endpoint isn't doing what you expect.

1. Prototype in the shell

The shell at Management → Shell runs in the same scope as an endpoint (you get Q(), Graph, Logger, etc.). Use it to iterate on the graph query before pasting it into the endpoint body:

// Prototype query in the shell
var hits = Q().StartAt("SupportCase")
              .SortByTimestamp(oldestFirst: false)
              .Take(5)
              .Emit("N")
              .ToList();
return hits;

When the shape looks right, copy it into the endpoint and wrap with Ok(...).

2. Use RelayStatusAsync for live feedback

RelayStatusAsync(message) streams a status line back to the caller while the endpoint runs. Invaluable for diagnosing where time is going in pooling endpoints:

await RelayStatusAsync("Resolving siblings...");
var siblings = ResolveSiblings();

await RelayStatusAsync($"Running search ({siblings.Length} candidates)...");
var hits = await SearchAsync(siblings);

The status messages show up in the chat UI and in MSK-ENDPOINT-STATUS response headers.

3. Log liberally

Logger.LogInformation("endpoint={Endpoint} user={UID} query={Query}",
    "kb-chat", CurrentUser, req.Query);

Stdout from Logger flows to Management → Logs and to your aggregator if you've wired one. Prefer structured logging over LogInformation($"... {value} ...") because aggregators index the fields.

For debugging, LogDebug shows in the workspace UI but not in production aggregators by default — perfect for noisy diagnostics.

4. Return intermediate values

During development, swap Ok(finalResult) for an object that includes the intermediate state:

return Ok(new {
    inputs       = req,
    siblings,
    hits         = hits.Select(h => new { h.Node["Id"], h.Score }),
    answer
});

Roll back once the endpoint behaves correctly — production responses should be lean.

5. Respect CancellationToken

For long-running endpoints, pass CancellationToken through every awaitable and check it manually around long synchronous loops:

foreach (var record in batch)
{
    CancellationToken.ThrowIfCancellationRequested();
    Map(record);
}

A pooling endpoint that doesn't honor cancellation can keep an LLM call running long after the caller has hung up — money down the drain.

6. Call one endpoint from another (cheap fixtures)

RunEndpointAsync<T>("path", body) runs a sibling endpoint inheriting the current user — great for writing a tiny test endpoint that hits the real one:

// Endpoint: kb-chat-smoke
var reply = await RunEndpointAsync<ChatReply>(
    "kb-chat",
    new { Question = "smoke", K = 3 });

return Ok(new
{
    HasAnswer    = !string.IsNullOrEmpty(reply.Answer),
    HasCitations = reply.Citations.Length > 0,
    Snippet      = reply.Answer[..Math.Min(80, reply.Answer.Length)],
});

For write endpoints from a replica, use RunEndpointOnPrimaryAsync<T> to forward to the primary node.

7. Inspect what CurrentUser resolves to

When debugging permissions, dump the caller's UID and role early:

var u = await Graph.GetUserByUidAsync(CurrentUser);
Logger.LogInformation("caller={Login} isAdmin={IsAdmin}", u?.Login ?? "(token)", u?.IsAdmin == true);

If CurrentUser is default, you're being called by an endpoint token — see Security best practices.

Common diagnostic patterns

Symptom Try
Empty results for some users Compare CreateSearchAsync vs CreateSearchAsUserAsync output.
Timeout in Sync mode Switch to Pooling; verify with RelayStatusAsync where time goes.
Endpoint compiles but never returns Missing Ok(...) / return in a branch.
"Body could not be deserialized" Logger.LogInformation the raw Body string and compare to JSON spec.
RunEndpointAsync failing with 403 Endpoint requires admin, but caller isn't admin; pass runAsAdmin: true (carefully) or change the target.

Referenced by

© 2026 Curiosity. All rights reserved.