Curiosity

Build your first enterprise AI app

This is the canonical end-to-end developer journey for Curiosity Workspace. By the end of it you will have:

  • A workspace running in Docker with persistent storage.
  • A typed graph schema for a small enterprise domain (we use support tickets).
  • A C# data connector that ingests sample data, sets stable keys, links entities with edges, and applies ACLs.
  • Text + vector search configured on the right fields.
  • A custom endpoint that does permission-aware, graph-scoped retrieval and returns a typed response.
  • An AI tool that lets the chat assistant call your endpoint with citations.
  • A deployment-validation checklist so you know what "done" looks like.

Estimated time: 30–45 minutes the first time, faster on re-runs.

If you only want to see the workspace running, do the Quickstart and stop there.

Prerequisites

Before you start, complete the developer prerequisites checklist: Docker, sample data, an LLM provider key, and the Curiosity.Library NuGet feed.


Step 1 — Start a clean local workspace

Use a generated admin password (no defaults) and an explicit storage path so we can validate persistence later.

mkdir -p ~/curiosity/first-app
ADMIN_PASSWORD=$(openssl rand -base64 24)
docker run --name first-app \
  -p 127.0.0.1:8080:8080 \
  -v ~/curiosity/first-app:/data \
  -e MSK_GRAPH_STORAGE=/data/curiosity \
  -e MSK_ADMIN_PASSWORD="$ADMIN_PASSWORD" \
  curiosityai/curiosity:latest
echo "Admin password: $ADMIN_PASSWORD"

Open http://localhost:8080, sign in as admin, and complete the setup wizard (workspace name, default language). You can leave SSO and provider configuration for later.

Step 2 — Design the domain model

We'll build the smallest support-ticket graph that exercises every Workspace feature you care about:

Customer ──HasTicket──▶ Ticket ──ForProduct──▶ Product
                          │
                          ├──MentionsEntity──▶ Entity   (NLP-extracted)
                          └──HasStatus──▶ Status

Why these choices:

  • Customer, Product, Ticket are entity-centric hubs users will navigate.
  • Status is modeled as a node (not a property) so we get a shared taxonomy and a facet "for free".
  • Entity mentions are added later by the NLP pipeline.
  • All edges are explicit and bidirectional names where it improves readability.

For the modeling rationale see Schema Design and Graph Design Patterns.

Step 3 — Create an API token for the connector

The connector authenticates with the workspace using an API token.

  1. Settings → API Tokens → Create token.
  2. Name it first-app-connector.
  3. Grant it the ingestion scope only.
  4. Copy the token — it is shown once. Store it in your secret manager (or .env).

See Token scopes for the full scope matrix.

Step 4 — Build the connector

Create a new .NET console app and add the Curiosity.Library NuGet package:

dotnet new console -n FirstApp.Connector
cd FirstApp.Connector
dotnet add package Curiosity.Library

Define the schema as plain C# classes — properties become graph properties, [Key] marks the deduplication identity, [Timestamp] enables time-aware sorting:

using Curiosity.Library;

[Node]
public class Customer
{
    [Key] public string Id { get; set; }
    [Property] public string Name { get; set; }
    [Property] public string Tier { get; set; }     // "Free" | "Pro" | "Enterprise"
}

[Node]
public class Product
{
    [Key] public string Sku { get; set; }
    [Property] public string Name { get; set; }
}

[Node]
public class Status
{
    [Key] public string Code { get; set; }          // "Open" | "Pending" | "Resolved"
    [Property] public string Label { get; set; }
}

[Node]
public class Ticket
{
    [Key] public string Id { get; set; }
    [Property] public string Subject { get; set; }
    [Property] public string Body { get; set; }     // long text — we'll embed this
    [Timestamp] public DateTimeOffset CreatedAt { get; set; }
}

public static class Edges
{
    public const string HasTicket   = nameof(HasTicket);
    public const string TicketOf    = nameof(TicketOf);
    public const string ForProduct  = nameof(ForProduct);
    public const string HasStatus   = nameof(HasStatus);
}

Write the ingestion loop. Schemas are registered once per run, then nodes are upserted by key, then edges link them. RestrictAccessToTeam ingests ACL metadata so the search engine can filter at query time.

using var workspace = await Workspace.ConnectAsync(
    baseUrl: Environment.GetEnvironmentVariable("WORKSPACE_URL") ?? "http://localhost:8080",
    apiToken: Environment.GetEnvironmentVariable("WORKSPACE_TOKEN"));

var graph = workspace.Graph;

// 1) Register schemas (idempotent)
await graph.CreateNodeSchemaAsync<Customer>();
await graph.CreateNodeSchemaAsync<Product>();
await graph.CreateNodeSchemaAsync<Status>();
await graph.CreateNodeSchemaAsync<Ticket>();
await graph.CreateEdgeSchemaAsync(typeof(Edges));

// 2) Reference data
var statusOpen     = graph.TryAdd(new Status { Code = "Open",     Label = "Open" });
var statusResolved = graph.TryAdd(new Status { Code = "Resolved", Label = "Resolved" });
var enterprise     = await graph.CreateTeamAsync("Enterprise Support", "Enterprise customers");

// 3) Stream source records → graph
foreach (var row in LoadTicketsFromCsv("tickets.csv"))
{
    var customer = graph.TryAdd(new Customer { Id = row.CustomerId, Name = row.CustomerName, Tier = row.Tier });
    var product  = graph.TryAdd(new Product  { Sku = row.Sku,        Name = row.ProductName });
    var status   = row.Status == "Resolved" ? statusResolved : statusOpen;

    var ticket = graph.TryAdd(new Ticket {
        Id        = row.TicketId,
        Subject   = row.Subject,
        Body      = row.Body,
        CreatedAt = row.CreatedAt,
    });

    graph.Link(customer, ticket,  Edges.HasTicket, Edges.TicketOf);
    graph.Link(ticket,   product, Edges.ForProduct);
    graph.Link(ticket,   status,  Edges.HasStatus);

    // Restrict Enterprise-tier tickets to the Enterprise team
    if (row.Tier == "Enterprise")
        graph.RestrictAccessToTeam(ticket, enterprise);
}

await graph.CommitPendingAsync();

Run it:

export WORKSPACE_TOKEN="<the token you created>"
dotnet run

Validate the ingest succeeded:

  • In the Workspace UI, open Search and confirm tickets are visible.

  • Open Graph → Explore and confirm Customer ─HasTicket─▶ Ticket ─ForProduct─▶ Product is wired up.

  • In Shell (or a temporary endpoint), run a smoke query:

    return Q().StartAt(nameof(Ticket)).Take(5).Emit("N");
    

For a deeper dive on connector design, see Connectors.

Step 5 — Configure search and embeddings

  1. Settings → Search → Indexes:
    • Index Ticket.Subject (high boost) and Ticket.Body.
    • Index Customer.Name and Product.Name for type-ahead lookup.
    • Add facets for Status, Product, and Customer.Tier.
  2. Settings → AI Settings:
    • Configure an embedding provider (for example OpenAI text-embedding-3-small).
    • Configure a chat provider (for example OpenAI gpt-4o-mini or Anthropic claude-haiku-4-5).
    • Enable embeddings on Ticket.Body with chunking on.
  3. Settings → Maintenance → Rebuild indexes to embed existing tickets.

See Text Search, Vector Search, and Hybrid Search for the tradeoffs.

Step 6 — Add a permission-aware custom endpoint

This is the core building block of an enterprise AI app: a server-side function that retrieves data as the calling user, applies your business logic, and returns a typed response that an LLM or a custom UI can consume.

  1. Settings → Custom Endpoints → Create endpoint named similar-tickets.
  2. Paste:
class SimilarTicketsRequest
{
    public string Query        { get; set; }   // user's text
    public string ProductSku   { get; set; }   // optional scope
    public int    Limit        { get; set; } = 10;
}

var req = Body.FromJson<SimilarTicketsRequest>();
if (string.IsNullOrWhiteSpace(req.Query))
    return BadRequest("query is required");

var search = SearchRequest.For(req.Query);
search.BeforeTypesFacet = new([] { nameof(Ticket) });

if (!string.IsNullOrWhiteSpace(req.ProductSku))
{
    search.TargetUIDs = Q().StartAt(nameof(Product), req.ProductSku)
                           .In(Edges.ForProduct)
                           .AsUIDEnumerable()
                           .ToArray();
}

// Permission-aware: runs in the calling user's security context
var query = await Graph.CreateSearchAsUserAsync(search, CurrentUser, CancellationToken);
return query.Take(req.Limit).Emit("N");

Why this is shaped the way it is:

  • SearchRequest.For(text) runs the hybrid retrieval pipeline (keyword + vector when embeddings are configured).
  • BeforeTypesFacet restricts results to tickets — we don't want products or customers leaking into a "similar tickets" UI.
  • TargetUIDs computes a graph-scoped target set (tickets for a product) before search runs. This is how you do "search within context" without a JOIN.
  • CreateSearchAsUserAsync(..., CurrentUser, ...) enforces ReBAC: the user only sees tickets they have access to. The Enterprise-only tickets we restricted in step 4 are automatically filtered out for unauthorized callers.

Test from the UI's endpoint console, then from curl:

curl -X POST "http://localhost:8080/api/endpoints/run/similar-tickets" \
  -H "Authorization: Bearer $WORKSPACE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "query": "screen flicker after firmware update", "limit": 5 }'

For more endpoint patterns see Custom Endpoints and the Custom queries reference.

Step 7 — Expose your endpoint to the AI assistant as a tool

AI Tools are C# classes the LLM can invoke during chat. They wrap a custom endpoint or piece of code, and they run inside the user's security context.

  1. Settings → AI Tools → Create tool named FindSimilarTickets.
  2. Paste:
public class TicketTools
{
    [Tool("Search the support-ticket knowledge base for tickets similar to the user's question. " +
          "Use this whenever the user asks 'have we seen this before?' or describes a symptom. " +
          "Cite results with the bracketed snippet id, e.g. [1].")]
    public static async Task<string> FindSimilarTickets(ToolScope scope,
        [Parameter("The symptom or question, in the user's own words", required: true)] string query,
        [Parameter("Optional product SKU to scope the search", required: false)] string productSku,
        [Parameter("Max results", required: false)] int limit)
    {
        var search = SearchRequest.For(query);
        search.BeforeTypesFacet = new([] { nameof(Ticket) });

        if (!string.IsNullOrWhiteSpace(productSku))
            search.TargetUIDs = scope.Graph.Q()
                .StartAt(nameof(Product), productSku)
                .In(Edges.ForProduct)
                .AsUIDEnumerable()
                .ToArray();

        var q = await scope.Graph.CreateSearchAsUserAsync(search, scope.CurrentUser, scope.CancellationToken);
        var results = q.Take(limit > 0 ? limit : 10).AsEnumerable().Select(n =>
        {
            var text = scope.ChatAI.GetTextFromNode(n.UID, limit: 4_000);
            var snippetId = scope.AddSnippet(uid: n.UID, text: text);
            return new {
                snippetId,
                ticketId = n.GetString(nameof(Ticket.Id)),
                subject  = n.GetString(nameof(Ticket.Subject)),
                body     = text,
            };
        }).ToArray();

        scope.SetToolCallDisplayName($"Looked for tickets similar to '{query}'");
        return results.ToJson();
    }
}
return new TicketTools();

Now go to the Chat view and ask: "Have we seen screen flicker issues on the Pro 14 product?" The assistant should call your tool, retrieve similar tickets, and answer with citations.

For tool-design best practices see AI Tools and Prompting Patterns.

Step 8 — Validate "done"

A short pre-flight checklist before you call this app "working":

  • Persistence: docker stop first-app && docker start first-app, then verify the workspace name, users, and data survived.
  • Permissions: Sign in as a non-admin user not in the Enterprise team and confirm Enterprise-tier tickets are absent from both search results and the chat assistant's citations.
  • Ingestion is idempotent: Re-run the connector. Node and edge counts should not change.
  • Hybrid retrieval works: Search a literal ticket ID (text retrieval) and a paraphrased symptom (vector retrieval); both should return relevant results.
  • Tool calls are traced: In the chat view, each AI answer should show which tools were called and which snippets were cited.

Step 9 — Move toward production

You now have the full development loop. To put this app in front of real users:

© 2026 Curiosity. All rights reserved.
Powered by Neko