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,Ticketare entity-centric hubs users will navigate.Statusis modeled as a node (not a property) so we get a shared taxonomy and a facet "for free".Entitymentions 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.
- Settings → API Tokens → Create token.
- Name it
first-app-connector. - Grant it the ingestion scope only.
- 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─▶ Productis 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
- Settings → Search → Indexes:
- Index
Ticket.Subject(high boost) andTicket.Body. - Index
Customer.NameandProduct.Namefor type-ahead lookup. - Add facets for
Status,Product, andCustomer.Tier.
- Index
- Settings → AI Settings:
- Configure an embedding provider (for example OpenAI
text-embedding-3-small). - Configure a chat provider (for example OpenAI
gpt-4o-minior Anthropicclaude-haiku-4-5). - Enable embeddings on
Ticket.Bodywith chunking on.
- Configure an embedding provider (for example OpenAI
- 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.
- Settings → Custom Endpoints → Create endpoint named
similar-tickets. - 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).BeforeTypesFacetrestricts results to tickets — we don't want products or customers leaking into a "similar tickets" UI.TargetUIDscomputes 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.
- Settings → AI Tools → Create tool named
FindSimilarTickets. - 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:
- Operations: pick a target platform from Installation, then follow the Deployment checklist, Backup & restore, and Monitoring.
- Identity: configure Single Sign-On and map SSO groups to teams so ReBAC works against your real user directory.
- Reliability: schedule the connector via Scheduled Tasks (full sync nightly, incremental every hour).
- Evaluation: assemble a small set of golden queries and re-run them on every deploy. See Search Optimization.
What to read next
- API Overview for the full picture of what's reachable from a connector or endpoint.
- Access Control Model for how ReBAC actually filters search.
- Examples gallery for blueprints in other domains (compliance, manufacturing, customer 360).