AI Assistant overview
The Genie Assistant is a natural-language chat that answers questions about your application’s data and how to use the app. A user asks in plain language (“how many tickets are open this week?”); the assistant generates SQL, runs it against the database, and replies with the answer as sanitised Markdown — figures, bullet lists, or a result table.
It is named Assistant everywhere in code (Genie.Engine/Features/Assistant). Under the hood it
is a provider-agnostic, MCP-style tool orchestrator streamed over SignalR and scoped to the
caller’s RBAC permissions — so it can only see and describe what the user is already allowed to see.
What it does
Section titled “What it does”- Natural language → SQL → answer. The model turns a question into a read-only
SELECT, the engine validates and runs it, and the model phrases the rows back in business language (never leaking table or column names). - App how-to questions. Beyond data, it answers “how do I add/edit X in the app?” from Markdown help docs (baseline engine docs + host docs).
- “What can I access?” It is given the caller’s roles and full permission map, so it can explain what the user may do and avoid suggesting actions they aren’t permitted to perform.
The MCP tool-orchestration model
Section titled “The MCP tool-orchestration model”Rather than a single prompt round-trip, the assistant runs a multi-turn tool-calling loop
(McpOrchestratorService). The provider protocol is embedded in the system prompt: to call a tool
the model replies with exactly
TOOL_CALL: <tool_name>PARAMS: {"key": "value"}The orchestrator parses that directive, dispatches the named tool, appends the result as a tool
message, and loops — until the model returns a plain-text answer, requests clarification, or the
iteration cap (5) is reached. This keeps the design provider-agnostic: any provider that can
emit text can drive the tools; no provider-specific function-calling API is required.
┌─ McpOrchestratorService · loop, max 5 turns ──────┐ │ system prompt: schema + app-docs + RBAC + rules │ │ │┌──────────┐ │ ┌─────────┐ ── TOOL_CALL ▶ ┌────────┐ │ ┌──────────────┐│ User │──┼─▶ │ model │ │ tool │ ├─▶ │ Markdown ││ question │ │ │ │ ◀─── result ── │ │ │ │ answer │└──────────┘ │ └─────────┘ └────────┘ │ └──────────────┘ └───────────────────────────────────────────────────┘The four tools
Section titled “The four tools”Each tool implements IAssistantTool (name, description, parameter schema, ExecuteAsync):
| Tool | What it does |
|---|---|
execute_query (ExecuteQueryTool) |
Validates a generated SELECT (SELECT-only, no dangerous keywords, row-capped) and runs it through IAssistantQueryExecutor, which enforces tenant scope at the DB layer. Returns rows as JSON. |
get_table_schema (GetTableSchemaTool) |
Returns the column definitions for a specific table when they weren’t already in the compact schema context. |
get_app_doc (GetAppDocTool) |
Returns the full Markdown body of an application help document by key (from the app-docs index in the system prompt) — for “how do I use X” questions. |
request_clarification (RequestClarificationTool) |
Asks the user one follow-up question when the request is ambiguous, optionally with 2–4 quick-action options the UI renders as buttons. Returns a sentinel the orchestrator relays back to the user. |
RBAC-scoped execution
Section titled “RBAC-scoped execution”The assistant never sees more than the user does. Two layers enforce that:
- Permission-filtered schema context. The schema library the model receives is built from
Genie’s modeled metadata (
EntityStore), not the raw DB catalog, and is filtered to tables the caller can reach through a view they hold View/List on. Engine and identity internals are never modeled as domain entities, so they are never described to the model. If the caller can access no tables — or asks about data outside their access — the model is instructed to answer plainly that they don’t have permission, rather than guess table names. - DB-enforced tenant isolation. For real isolation the generated SQL runs through a dedicated
read-only, least-privilege DB login, and the caller’s
CompanyId/UserId/IsSystemis pushed into a DB session context so Row-Level Security filters rows regardless of what SQL the model wrote. See Tenant isolation & SQL hardening.
This mirrors the engine-wide rule: prompt text is a hint, not an authorization boundary — the database re-enforces access. See the Security model.
SignalR streaming
Section titled “SignalR streaming”The chat runs over a dedicated SignalR hub, AssistantChatHub (/hubs/assistant-chat), separate
from the notification hub. As the model works, the hub streams intermediate output to the caller:
ReceiveReasoningChunk— chain-of-thought reasoning streamed live while the model thinks (shown in an expandable Thinking disclosure that collapses once the answer lands).ReceiveMessage— the final assistant reply (Markdown, plus generated SQL for admins and a result table for data answers).ConversationStarted/ConversationRenamed— a brand-new thread is announced only once its first reply exists, then its AI-generated title is pushed live.ReceiveError/MessageCancelled— role-masked errors and cancellation of an in-flight reply (CancelMessage).
If the hub is unavailable the UI falls back to REST (POST /api/assistant/query).
DB-backed multi-chat
Section titled “DB-backed multi-chat”Conversations are persisted in the database (schema Genie, tables AssistantChat and
AssistantChatMessage) — not Redis — so threads survive restarts and each user can keep multiple
chats. Key behaviours:
- Each thread is owned by its user (
UserId+CompanyId); non-admins only ever see their own. - A thread is persisted only once its first reply succeeds — a failed first turn is rolled back, so there are no empty ghost threads.
- Titles start as the first message truncated, then (when
AutoGenerateTitleis on) the provider summarises the first message into a concise title that updates live; a manual rename always wins.
Prompt context: what the assistant knows
Section titled “Prompt context: what the assistant knows”Every request injects three things into the system prompt, assembled by the orchestrator:
- A schema library from
EntityStore(dual-database safe, modeled types/enums/FK targets, permission-filtered as above; falls back to a raw catalog read only when no entities are modeled). - An app-documentation index — a compact list of Markdown help docs; the model pulls a full
doc on demand via
get_app_doc. The engine ships a baseline set; a host adds its own viaAssistant:DocsPath(host wins on collision). - The caller’s roles and full permission map (from
IPermissionManager) plus mandatory security rules (SELECT-only, soft-delete filtering, and — for non-admins — a hardCompanyIdfilter requirement).
Where to go next
Section titled “Where to go next”- Configuration & providers — pick and configure a provider, wire
the backend and the
createGenieAppmodule, and tune persistence/titles/error masking. - Tenant isolation & SQL hardening — make tenant scope enforced by the database, not the prompt.
- Security model — how the assistant fits the layered security model.