This is the full developer documentation for Genie
# Genie
> Describe your data and screens in small XML files. Genie turns them into working, model-driven tables and forms — with auth, RBAC, workflow, wizards and more already built in.
## The 30-second version
[Section titled “The 30-second version”](#the-30-second-version)
You write small XML models — entities, views, navigation, SQL. **`Genie.Source`** turns entities into EF Core tables at compile time; **`Genie.Engine`** serves them as JSON (metadata + data + RBAC) at runtime; **`genie-engine-ui`** renders them in React. The backend never returns HTML.
Author XML
Entities, views, navbar and SQL. One `*.view.xml` describes a grid *and* a form — capabilities are inferred from the SQL blocks present.
Compile to tables
A Roslyn source generator materialises `*.entity.xml` into EF Core entities so a normal `dotnet ef migrations add` creates real tables.
Serve as JSON
The engine resolves views per request, runs the SQL, enforces RBAC, and returns metadata + values — never markup.
Render in React
`createGenieApp({...})` wires a configurable React 19 app: tables, forms, views, navbar, auth, theme and an eval-free expression engine.
## Explore the guide
[Section titled “Explore the guide”](#explore-the-guide)
Introduction
The mental model, the three packages, and the JSON-not-HTML rule. [Start here](/introduction/overview/).
Model Authoring
The complete XML contract — [entities](/model-authoring/entities/), [views](/model-authoring/views/), layout, actions, imports and SQL.
Integration
Drop Genie into your [backend](/integration/backend/) and [React frontend](/integration/frontend/).
Security
[RBAC](/security/rbac/), the disclosure gate, [multi-tenancy](/security/multi-tenancy/) and identity.
# Architecture
> The unified Object model, the facade over chunk services, and how a request flows.
This page explains how the engine is put together — enough to know *where* a feature lives and *how* a request turns into JSON. For the day-to-day authoring surface, jump to [Model Authoring](/model-authoring/files-and-conventions/).
## The unified Object model
[Section titled “The unified Object model”](#the-unified-object-model)
A `View` used to be two parallel types (`TableView` / `FormView`). It is now **one `ObjectView`** (defined in `Genie.Source`), with capabilities inferred from which SQL blocks are present. The runtime surface is a **facade over focused chunk services** (interface segregation):
```text
┌─▶ ObjectGridService grid / table ops
ObjectController ─▶ IObjectService ──┼─▶ ObjectFormService structure · values · submit
(the facade) └─▶ IViewResolver ──┬─▶ ViewStore (ObjectView JSON)
└─▶ IViewRegistry (system views)
```
`ObjectService` is a thin delegator — the real logic lives in the chunk services and their helpers (`FormServiceHelper`, `TableServiceHelper`, `ParameterSanitizer`) under `Services/Object`.
Views are resolved either from the **`ViewStore`** (the EF table holding polymorphic `ObjectView` JSON, populated by model migration) or from a hardcoded **`IViewRegistry`** for system views (Auth, Workflow, Wizard) — each shipping both SQL Server and PostgreSQL variants.
## How a request flows
[Section titled “How a request flows”](#how-a-request-flows)
1. The React client calls `GET /object/{name}/metadata` **once** to get the SQL-free schema, then decides whether to render a table, an editable form, or a read-only view.
2. For a grid it calls `POST /object/{name}/table` with filters/paging; for a record it calls `POST /object/{name}/form` or `/view`.
3. The controller delegates to `IObjectService`, which resolves the `ObjectView`, runs the appropriate SQL block through the dual-DB query path, and applies RBAC + parameter sanitisation.
4. The result is wrapped in a `SuccessDataResult` envelope. The UI merges **structure** (from metadata) with **values** (from form/view) client-side.
See the [API Reference](/api-reference/overview/) for the exact endpoint contracts.
## Metadata vs data: the disclosure boundary
[Section titled “Metadata vs data: the disclosure boundary”](#metadata-vs-data-the-disclosure-boundary)
This split is central to the security model — respect it when adding features:
* **Metadata** (`GET /object/{name}/metadata`) is SQL-free structure. It never returns SQL or record values, and is ungated beyond authentication.
* **Data** (`POST /object/{name}/{table,form,view}` and the mutation endpoints) is gated by the RBAC **data-disclosure gate**: loading record values requires View/Update, `submit` requires Create/Update, `delete-row` requires Delete.
Any `permissions` / `canSubmit` flags in a DTO are **UI hints only** — the server re-validates on every operation. See [The disclosure gate](/security/disclosure-gate/).
## Errors
[Section titled “Errors”](#errors)
A global exception handler turns any unhandled exception into a consistent JSON envelope `{ success: false, error, traceId }`, with a status code mapped from the exception type (`UnauthorizedException` → 401, `NotFoundException` → 404, `ArgumentException`/`RuntimeException`/`GenieException`/`InvalidOperationException` → 400, else 500). Throw the right exception type from services and let the handler shape the response. See [Errors & exception handling](/integration/errors/).
## Persistence
[Section titled “Persistence”](#persistence)
`GenieContext` (EF Core) runs over SQL Server **and** PostgreSQL, selected by `Genie:Datasource`. The `ViewStore` table holds polymorphic `ObjectView` JSON. Traits add audit, tenant (`CompanyId`) filtering, and soft-delete. Redis backs the cache (refresh tokens, MFA gates) and the DataProtection key ring.
Dual database
Anything touching SQL must work for both SQL Server and PostgreSQL — hardcoded system views ship both dialects. Keep this in mind when authoring `*.sql` objects and view SQL blocks.
# Overview
> What Genie is, the mental model, and the single rule that shapes the whole codebase.
Genie is a **low-code framework**: you describe your data and screens in small XML files, and Genie turns them into working, model-driven **tables and forms** backed by a real database — with auth, RBAC, workflow, wizards, sequences, imports/exports and notifications already built in.
This repository is a **re-platform** of the original server-rendered Genie into a **JSON API + React front end**. The single rule that shapes the whole codebase follows from that:
The one rule
The backend returns **data + model metadata as JSON — never HTML**. All rendering lives in the React UI.
Structure and values come from the engine; layout, DOM and presentation are owned by the UI.
## The mental model
[Section titled “The mental model”](#the-mental-model)
```text
author XML compile + run render
*.entity.xml ─┐ ┌─ Genie.Source (source gen) ─┐ ┌─ genie-engine-ui (React)
*.view.xml ─┼──────▶ │ → EF entities + tables │ JSON │ GenieTable / GenieForm /
*.sql ─┤ ├─ Genie.Engine (runtime) │ ─────▶ │ GenieView / GenieNavbar
*.navbar.xml ─┘ └─ → metadata + data + RBAC ─┘ └─ from createGenieApp({...})
```
1. **You write XML models** — entities (tables), views (grids/forms), navigation, and SQL objects.
2. **`Genie.Source`** (a Roslyn source generator + the schema types + the XML parsers) turns `*.entity.xml` into strongly-typed EF Core entities at **compile time**, so a normal `dotnet ef migrations add` materialises real tables.
3. **`Genie.Engine`** (the runtime) loads the views/entities at **startup** (model migration), resolves them per request, runs the SQL, enforces RBAC, and returns **JSON** (metadata + values).
4. **`genie-engine-ui`** (React) consumes that JSON and renders the tables, forms, views, navbar, auth screens and theme — configured entirely in code via `createGenieApp({...})`.
## A view is one unified object
[Section titled “A view is one unified object”](#a-view-is-one-unified-object)
A `*.view.xml` file (root element `
`) describes **one object**. Whether it behaves as a grid, an editable form, or a read-only view is **inferred from which SQL blocks it declares** — there is no separate “table view” vs “form view” type. Internally this is the unified `ObjectView`, and the runtime is a facade over focused query / form / action services.
## Endpoints: structure vs data
[Section titled “Endpoints: structure vs data”](#endpoints-structure-vs-data)
A boundary worth knowing up front — the React client reads the **schema once**, then loads data:
* **`GET /api/v1/genie/object/{name}/metadata`** — the object’s **SQL-free schema** (type, capabilities, columns, fields, layout). Static and cacheable; no SQL, no per-user row values. The UI decides *table vs form vs view* from this.
* **`POST /api/v1/genie/object/{name}/table`** — a page of grid **rows** (+ pagination + per-user permission facts).
* **`POST /api/v1/genie/object/{name}/form`** / **`/view`** — a single record’s **field values**, editable or read-only.
* **`POST /api/v1/genie/object/{submit,delete-row,execute-row-action,export-data,import-data,…}`** — mutations and bulk operations, behind the RBAC data-disclosure gate.
Every response is wrapped in a `SuccessDataResult` envelope. See the [API Reference](/api-reference/overview/) for the full list.
## Where to go next
[Section titled “Where to go next”](#where-to-go-next)
* **[Quickstart](/getting-started/quickstart/)** — run the sample and add your first entity + view in minutes.
* **[The three packages](/introduction/packages/)** — what `Genie.Source`, `Genie.Engine` and `genie-engine-ui` each do.
* **[Model Authoring](/model-authoring/files-and-conventions/)** — the complete XML authoring reference (every field type, layout, action, filter, badge).
* **[Integration](/integration/backend/)** — drop Genie into your own backend + frontend.
# The three packages
> What Genie.Source, Genie.Engine and genie-engine-ui each do.
This monorepo publishes three packages. Install/auth details are in [Consuming the packages](/packages/consuming/).
| Package | Type | Path |
| ------------------------------------- | ---------------------- | ------------------------ |
| `Genie.Source` | NuGet (netstandard2.0) | `src/api/Genie.Source` |
| `Genie.Engine` | NuGet (net10.0) | `src/api/Genie.Engine` |
| `@orbyn-technologies/genie-engine-ui` | npm (React 19 + TS) | `src/ui/genie-engine-ui` |
## `Genie.Source` — the contract & code generator
[Section titled “Genie.Source — the contract & code generator”](#geniesource--the-contract--code-generator)
The **compile-time** half and the source of truth for the XML contract.
* **XML parsers** (`ViewSchemaXmlParser`, `EntitySchemaXmlParser`, `NavbarXmlParser`, …) — turn the `*.entity.xml` / `*.view.xml` / `*.navbar.xml` files into strongly-typed schema objects.
* **The view-model schema** — `ObjectView`, `ObjectSql`, the `EditorField` family, the `FormControl` layout tree (Section / Tab / Item / Line / Sub / Grid / Row / Column), and the `EntitySchema` / `Field` model.
* **The Roslyn incremental generator** — reads `*.entity.xml` files passed as `AdditionalFiles` and emits, into the **consuming** project:
* an EF entity class per entity (deriving `EntityTraits`), with enums for `Select` fields and FK navigations for `Lookup` fields;
* an `EntityBaseConfiguration` per entity (table / schema / indexes / relationships);
* a `DbSet<>` for each entity, emitted as a **partial of your host `DbContext`** (the class name is configurable via the `GenieDbContextName` build property; default `ZedContext`).
Because the generated configurations are `IEntityTypeConfiguration` in your assembly, your `GenieContext`-derived context registers them automatically.
## `Genie.Engine` — the runtime
[Section titled “Genie.Engine — the runtime”](#genieengine--the-runtime)
The **net10.0** runtime that host apps reference. Seven concern roots:
```plaintext
Api/ Controllers + SignalR hubs (the HTTP surface), segregated per area
Core/ Foundation, no business logic: Abstractions, Framework, Persistence
Services/ Engine-internal runtime services: Object, ViewResolution, Authorization, …
Features/ Bounded capabilities: Identity, Workflow, Wizard, Assistant, Reports, …
Contracts/ Request/result DTOs
Handlers/ Pluggable export/import handlers
Hosting/ Exception handler, middleware, security, DI composition
```
Highlights:
* **Unified `ObjectView`** — a view is one polymorphic object; whether it behaves as a grid or a form is inferred from which SQL blocks it declares. The runtime is a facade (`IObjectService`) over focused chunk services (query / form / action).
* **Model migration on startup** — the migration service scans the `models` directory and applies each file via a per-type **strategy** (entity → store, view → `ViewStore`, SQL → executed, navbar → synced). See [Model migration](/integration/model-migration/).
* **Identity & RBAC** — JWT + cookie auth, MFA/TOTP, sessions; per-object View/Create/Update/Delete verbs re-validated on **every** operation. Also workflow, wizard, reports, sequences, search and notifications.
## `genie-engine-ui` — the React front end
[Section titled “genie-engine-ui — the React front end”](#genie-engine-ui--the-react-front-end)
A configurable React 19 + TypeScript app. You call `createGenieApp(config)` and it renders:
* `GenieTable` / `GenieForm` / `GenieView` — driven by the engine’s metadata + values;
* `GenieNavbar` / `GenieLayout` / `GenieRouter` — `/table|form|view/{name}` routing;
* the auth experience (login, MFA, profile), theme, and an eval-free expression engine for field required/disabled/hidden/value rules.
The DOM/container ids are generated **client-side** — the server ships no markup. See [Frontend & Theming](/frontend/components/).
# Quickstart
> Run the Inventory reference app, then author your own entity and view in about ten minutes.
Get the reference app running, then add your own entity + view. About 10 minutes.
New here?
Read the **[Overview](/introduction/overview/)** first for the mental model — XML models in, JSON metadata + data out, React renders it.
## Prerequisites
[Section titled “Prerequisites”](#prerequisites)
* **.NET SDK** matching [`global.json`](/introduction/packages/) (net10.0, SDK 10.0.300+).
* **Node 22+** and npm (for the React UI).
* **SQL Server** reachable at `Server=.` (the local default instance, integrated security).
No Redis required — the Inventory sample runs its cache **in-memory**. Adjust the connection string in `sample/Inventory/api/appsettings.json` for your environment:
```json
"ConnectionStrings": {
"SqlServer": "Server=.;Database=InventorySample;Integrated Security=true;TrustServerCertificate=True;MultipleActiveResultSets=True"
}
```
## 1. Build
[Section titled “1. Build”](#1-build)
```bash
dotnet build src/Genie.slnx # .NET libraries + source generator
cd src/ui/genie-engine-ui && npm ci && npm run build # the React package
```
## 2. Run the reference app (Inventory)
[Section titled “2. Run the reference app (Inventory)”](#2-run-the-reference-app-inventory)
```bash
# backend → http://localhost:5184
# (creates the InventorySample DB, applies the migration, and seeds sample data on first run)
dotnet run --project sample/Inventory/api
# frontend → http://localhost:5183 (proxies /api, /files, /hubs to the backend)
cd sample/Inventory/ui && npm install && npm run dev
```
Open : you get the login page, then the permission-filtered navbar and the engine’s table/form views.
First login
Sign in as `system` with `Genie:Auth:PasswordEncryption:SeedAdminPassword` (default `Admin@123`). Seeded sample users `buyer` and `approver` share the same password. Full sample wiring and notes are in `sample/Inventory/README.md`.
## 3. See the model files
[Section titled “3. See the model files”](#3-see-the-model-files)
The sample’s low-code models live in `sample/Inventory/models/`:
```plaintext
models/
entities/ *.entity.xml Category, Product, Supplier, Warehouse, StockMovement,
PurchaseOrder, PurchaseOrderLine (→ EF tables)
views/ *.view.xml grids + forms for each object
sql/ *.sql functions, stored procedures, triggers, reporting views
navbar/ *.navbar.xml the navigation tree
wizards/ *.wizard.xml multi-step wizards
workflows/ *.workflow.xml workflow definitions
```
They are wired in `sample/Inventory/api/Inventory.Sample.Api.csproj`: `*.entity.xml` are fed to the source generator as `AdditionalFiles`, and **every** model file is copied to the build output so the runtime model migration can find it.
## 4. Add your own entity
[Section titled “4. Add your own entity”](#4-add-your-own-entity)
Create `sample/Inventory/models/entities/Brand.entity.xml`:
```xml
```
Don’t declare an `Id`
Every entity inherits `Id` plus audit / tenant (`CompanyId`) / soft-delete (`IsDeleted`) columns from `EntityTraits`. The full field reference is in [Model Authoring](/model-authoring/entities/).
The generator picks it up on the next build (it matches the existing `AdditionalFiles` glob) and emits a `Brand` entity, its EF configuration, and a `DbSet Brands` on `InventoryContext`.
## 5. Materialise the table (EF migration)
[Section titled “5. Materialise the table (EF migration)”](#5-materialise-the-table-ef-migration)
The generated entity classes are part of `InventoryContext`, so a normal migration creates the table. **Stop the running host first** (EF rebuilds the project, which can’t overwrite a locked, running binary):
```bash
dotnet ef migrations add AddBrand \
--project sample/Inventory/api \
--startup-project sample/Inventory/api \
--context InventoryContext \
--output-dir Migrations/InventoryMigrations
```
Run the host again; the migration is applied at boot (`Database.Migrate()`).
## 6. Add a view
[Section titled “6. Add a view”](#6-add-a-view)
Create `sample/Inventory/models/views/Brands.view.xml` — the root element is `
`, and whether it behaves as a grid, a form, or a read-only view is inferred from which SQL blocks it declares:
```xml
```
Views are loaded into the engine’s `ViewStore` by the **model migration** at startup. The Inventory sample already enables it in `sample/Inventory/api/appsettings.json`:
```json
"Genie": {
"Migration": {
"MigrationExecution": "Forced",
"ModelsPath": "models"
}
}
```
`No` skips migration, `Yes` applies only changed files (hash check), and `Forced` re-applies every file regardless of hash. `ModelsPath` says where the model files are — relative to the app output (as here, where the sample copies `../models/**` into `bin/.../models`) or an absolute path. See [Model migration](/integration/model-migration/).
## 7. Use it
[Section titled “7. Use it”](#7-use-it)
Point the browser (or the navbar) at `/object/brands` — the UI route uses the view’s kebab-case `Slug`. To add it to the navigation, drop an `` into `sample/Inventory/models/navbar/inventory.navbar.xml`:
```xml
```
Under the hood
The React client reads the object’s SQL-free schema once from `GET /api/v1/genie/object/brands/metadata`, then loads data with `POST /api/v1/genie/object/brands/{table,form,view}`. Structure and values are separate calls, merged client-side.
To make records findable from the **global header search** (enable it with `createGenieApp({ modules: { headerSearch: true } })`), mark a field `Searchable="true"` and add a `` block to the entity — see [Model Authoring](/model-authoring/entities/).
***
### Recap of the loop
[Section titled “Recap of the loop”](#recap-of-the-loop)
| Want to… | Edit | Then |
| ---------------------------- | -------------- | --------------------------------------------------- |
| Add/lengthen a column | `*.entity.xml` | rebuild → `dotnet ef migrations add` → run |
| Change a grid/form | `*.view.xml` | run with `MigrationExecution` = `Yes` (or `Forced`) |
| Add a stored proc / SQL view | `*.sql` | run with `MigrationExecution` = `Yes` |
| Change navigation | `*.navbar.xml` | run with `MigrationExecution` = `Yes` |
Next: the complete authoring reference → **[Model Authoring](/model-authoring/entities/)**.
Ready to ship? **[Deployment](/integration/deployment/)** shows how to serve the built UI from the API host (`UseGenieSpa` — one deployable, zero CORS preflights) or behind a reverse proxy.
# Object endpoints
> The complete object surface — metadata, grid data, record values, mutations, import/export and uploads.
All object operations live on a single controller, `ObjectController`, under:
```plaintext
/api/v1/genie/object
```
Every endpoint is `[Authorize]`. An object is addressed by its **name** (the view name/slug). The former separate `/table` and `/form` controllers, and the legacy `GET /metadata/{name}` and `POST /object/values` routes, no longer exist — everything is unified here.
## Schema & data
[Section titled “Schema & data”](#schema--data)
| Method | Route | Returns | Purpose | Gate |
| ------ | ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------- |
| `GET` | `/{name}/metadata` | `MetadataResult` | SQL-free schema (the `ObjectView` with SQL stripped). The UI decides table vs form vs view from this. Static & cacheable. | auth only |
| `POST` | `/{name}/table` | `TableResult` | A page of grid rows + pagination + per-user permission facts. | View |
| `POST` | `/{name}/form` | `FormResult` | A record’s field values for an **editable** form (first row of the form SQL). | View / Update |
| `POST` | `/{name}/view` | `FormResult` | A record’s field values for a **read-only** view (view mode forced). | View |
`POST /{name}/table` accepts a `TableRequest` body (filters, search text, sort, page/pageSize). `POST /{name}/form` and `/{name}/view` accept a `FormRequest` body (the record’s primary-key / parent parameters). An omitted body is treated as an empty request.
The `TableResult`/`FormResult` **permission facts** (`Permissions`) carry the standard verb booleans (`Create`/`Update`/`Delete`/`Export`/`Import`) plus a `Capabilities` list naming the caller’s [business capabilities](/security/rbac/#capabilities) beyond them (e.g. `["Approve"]`). Rows returned by `/table` and `/form`/`/view`, and exports, are already [row-filtered](/security/rbac/#filters) by the caller’s `List` filter, and each row may carry `Permit__` cells answering [per-row conditions](/security/rbac/#conditions).
Read schema once
The React client calls `/{name}/metadata` a single time, caches the structure, then repeatedly calls `/table`, `/form` or `/view` for data. Structure and values are merged client-side.
## Mutations
[Section titled “Mutations”](#mutations)
| Method | Route | Returns | Purpose | Gate |
| ------ | --------------------- | -------------------- | ------------------------------------------------------------------ | --------------- |
| `POST` | `/submit` | `ObjectActionResult` | Create or update a record (`SubmitRequest` — requires `FormName`). | Create / Update |
| `POST` | `/delete-row` | `ObjectActionResult` | Delete a row (`DeleteRowRequest`). | Delete |
| `POST` | `/execute-row-action` | `ObjectActionResult` | Run a declared row action’s SQL (`ExecuteRowActionRequest`). | per action |
Mutation results can carry **dispatch envelopes** (e.g. `refreshTable`, `showAlert`) that tell the UI what to do next — see [Actions & row actions](/model-authoring/actions/).
### Optimistic concurrency (409)
[Section titled “Optimistic concurrency (409)”](#optimistic-concurrency-409)
When an entity declares [`Concurrency="Enabled"`](/model-authoring/entities/#concurrency), the record’s `RowVersion` stamp is loaded with the form and **sent back** in `SubmitRequest.Parameters` (and, for the atomic authored-SQL path, `DeleteRowRequest.RowVersion`). If the stored stamp has moved on since the record was loaded, the mutation is rejected with **HTTP 409 Conflict** (envelope `{ "success": false, "error": "This record was changed by someone else…" }`) instead of silently overwriting the other change. `RowVersion` is a reserved parameter — it passes the mass-assignment sanitizer and is never written as data.
### Form load time
[Section titled “Form load time”](#form-load-time)
`FormResult` (the `/{name}/form` and `/{name}/view` payload) carries **`FormLoadTime`** — the UTC instant (ISO round-trip) at which the record was loaded, stamped server-side. Like `RowVersion` it is a reserved parameter the client sends back in `SubmitRequest.Parameters`, and the submit binds it as `@FormLoadTime` for the authored SQL (falling back to “now” when a caller supplies none). It is never written as data. Being client-round-tripped it can be replayed — treat it as an audit/heuristic value, not a security control. See [Parameters the submit blocks always receive](/model-authoring/views/#parameters-the-submit-blocks-always-receive).
### Field validation (400)
[Section titled “Field validation (400)”](#field-validation-400)
A submit (or `/import-data`) that fails field validation — required, `RegexValidation`, a type bound, or an author-declared `` rule — is rejected with **HTTP 400** whose envelope carries `fieldErrors` (field name → message) alongside `error`, so the client can highlight and focus the offending field. Rules declared `Type="Sql"` run server-side only, which is exactly why the response has to name the field. See [Validation](/model-authoring/editor-fields/#validation).
### Idempotency (safe retry)
[Section titled “Idempotency (safe retry)”](#idempotency-safe-retry)
Every mutation above (plus `/import-data` and `/upload`) honours an optional **`Idempotency-Key`** request header. Send a unique key with a write and the engine remembers the first response (scoped per user + company, for `Genie:Idempotency:Ttl`, default 24 h); a repeat of the same key **replays that response** instead of re-running the mutation — so a network retry or double-submit can’t create duplicate rows. A duplicate that arrives while the first is still in flight gets a 409. Configure via [`Genie:Idempotency`](/integration/configuration/); omit the header to opt out per request.
## Export & import
[Section titled “Export & import”](#export--import)
| Method | Route | Returns | Purpose | Gate |
| ------ | ------------------------------------------ | -------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------- |
| `POST` | `/export-data` | `ObjectActionResult` | Generate an export file under `wwwroot/Exports` (`ExportTableRequest`). | View / Export |
| `GET` | `/export-file/{fileName}` | file stream | Download a previously-generated export as an authenticated attachment. Bare file name only; traversal rejected. | auth |
| `POST` | `/import-data` | `ObjectActionResult` | Bulk import (`ImportTableRequest`, multipart form). Body cap from `Genie:Uploads:MaxImportBytes` (default 1 GiB). | Import |
| `GET` | `/{name}/import-template?format=xlsx\|csv` | `ObjectActionResult` | Generate a header-only import template; download via `export-file`. | Import |
Import/export column mapping and custom handlers are authored in the view — see [Bulk import & handlers](/model-authoring/import/).
## Field datasets, sequences & uploads
[Section titled “Field datasets, sequences & uploads”](#field-datasets-sequences--uploads)
| Method | Route | Returns | Purpose |
| -------- | --------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `POST` | `/field-dataset` | `List>` | Options for a dependent `Select` field (`FieldDataSetRequest` — requires `FormName` + `FieldName`). |
| `POST` | `/sequence-number` | `string` | Preview/reserve a formatted sequence number (`SequenceNumberRequest`). See [Sequences](/platform/sequences/). |
| `POST` | `/upload` | `List` | Upload temporary attachment files (multipart: `objectName`, `fieldName`, `viewId`). Size limit 100 MB. |
| `DELETE` | `/upload/{uploadKey}` | message | Remove a temporary uploaded file before submit. |
| `GET` | `/label/{objectName}` | `string` | The view’s display label. |
## Related object controllers
[Section titled “Related object controllers”](#related-object-controllers)
* `GET/POST /api/v1/genie/object-explorer/...` — the object explorer surface.
* `/api/v1/genie/rpc/...` — stored-procedure / RPC calls (`ProcedureCallController`).
See [Other endpoints](/api-reference/other-endpoints/) for the non-object controllers.
# Other endpoints
> The non-object controllers — auth, navigation, search, reports, notifications, wizards, workflows and admin.
Beyond the [object surface](/api-reference/object-endpoints/), the engine exposes these controller areas. Each base route hosts the operations for one feature; follow the linked guide for behaviour and payloads.
## Identity & access
[Section titled “Identity & access”](#identity--access)
| Base route | Controller | Covers |
| ------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/api/v1/auth` | `AuthController` | Login, refresh, logout, MFA challenge/verify, password reset. |
| `/api/v1/genie/auth` | `AuthorizationBoardController` | The Access Dashboard: statistics, the access workspace (`/board/principals` for the rail and scope options, `/board/matrix` for one principal’s access, `/board/apply` for a batch of toggles), the access-explorer trees, and RBAC import/export. `/board/matrix` accepts a `Scope` of `All`/`Global`/`Company` that only ever narrows the caller’s own scope. Open to `System`, `Admin` and `AccessManager`; import/export is `System`-only. |
| `/api/v1/genie/company` | `CompanyController` | Tenant/company listing & switching. |
| `/api/v1/impersonation` | `ImpersonationController` | Start impersonating a user (System role) and stop again (any authenticated session — it runs as the impersonated user). |
| `/api/v1/genie/timezone` | `TimeZoneController` | Get/set the user’s IANA time zone. |
See [Identity](/security/identity/), [RBAC & permissions](/security/rbac/) and [Multi-tenant company scoping](/security/multi-tenancy/).
## Navigation, search & content
[Section titled “Navigation, search & content”](#navigation-search--content)
| Base route | Controller | Covers |
| ----------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/api/v1/genie/navbar` | `NavbarController` | The permission-filtered navigation tree (badge-free, returns fast). |
| `/api/v1/genie/navbar/badges` | `NavbarController` | Computed badge values (counts / dots) keyed by item name, fetched separately so the tree renders first. |
| `/api/v1/genie/search` | `SearchController` | Global search (SQL or Meilisearch provider). |
| `/report` | `ReportController` | Code-defined reports: `GET /report` lists them, `GET /report/{key}` generates one (`?inline=true` to preview rather than download). A **root** route, not under `/api/v1/genie`. |
| `/files/{path}` | `FileController` | Streams a stored file (attachment/export/temp), inline where the type allows. Also a root route. |
See [Navigation](/platform/navigation/), [Search](/platform/search/) and [Reports](/platform/reports/).
## Notifications
[Section titled “Notifications”](#notifications)
| Base route | Controller | Covers |
| --------------------------- | ---------------------------- | --------------------------------- |
| `/api/v1/notification` | `NotificationController` | A user’s notifications. |
| `/api/v1/notifications` | `NotificationApiController` | Notification API surface. |
| `/api/v1/push-subscription` | `PushSubscriptionController` | Web-push subscription management. |
Real-time delivery is over SignalR — see [Notifications](/platform/notifications/).
## Wizards & workflows
[Section titled “Wizards & workflows”](#wizards--workflows)
| Base route | Controller | Covers |
| ------------------------ | ---------------------------- | ------------------------------------------------- |
| `/api/v1/genie/wizard` | `WizardController` | Wizard execution and form generation. |
| `/api/v1/genie/workflow` | `WorkflowDesignerController` | Workflow definitions, instances and the designer. |
See [Wizards](/wizards/overview/) and [Workflows](/workflows/overview/).
## Report pages
[Section titled “Report pages”](#report-pages)
Two calls, mirroring the object endpoints’ structure/data split: structure is SQL-free and fetched once, data is gated and re-fetched per filter change.
| Method & route | Returns | Gate |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------ |
| `GET /api/v1/genie/reports/{key}` | The report’s filters, widgets, layout and dataset names/modes. Every `[SqlBody]` property is stripped, so no query text leaves the server. | Authentication only |
| `POST /api/v1/genie/reports/{key}/data` | Every dataset a visible widget reads, executed once each. | `List` on the report’s RBAC resource |
`{key}` is the report’s `Name` or `Slug`. Plural `/reports` — distinct from the singular [`/report/{key}`](#files-jobs--scripting) at the API root, which belongs to the unrelated code-defined PDF feature.
```jsonc
// POST /api/v1/genie/reports/inventory-overview/data
{
"Arguments": { "FltWarehouse": "3", "FltRangeFrom": "2026-08-27", "FltRangeTo": "2026-09-09" }
}
```
```jsonc
// → SuccessDataResult
{
"success": true,
"data": {
"Name": "InventoryOverview",
"RequiredFilterMissing": false,
"DataSets": {
"DsKpis": {
"Columns": [{ "Name": "StockValue", "ClrType": "Decimal" }],
"Rows": [{ "StockValue": "1284750.00" }]
}
},
"AccessibleWidgets": ["TlStockValue", "ChCategoryValue", "VwLowStock"]
}
}
```
Three things to note about the response:
* **`RequiredFilterMissing: true` means no dataset ran at all.** A blank required filter gates the whole page; `DataSets` is empty and the client shows a prompt.
* **`AccessibleWidgets` is authoritative.** A widget withheld by `RolesAllowed` is absent, and so is any dataset only it read — the client closes the layout over it.
* **Every cell is a string or null**, numbers included, matching the grid. Client code must parse and guard `NaN`.
An embedded `` widget is *not* served by these endpoints: it fetches through the ordinary [object endpoints](/api-reference/object-endpoints/) under its own resource, so embedding never launders a permission.
See [Report pages](/reports/overview/) and [Runtime & sources](/reports/runtime/).
### Report designer
[Section titled “Report designer”](#report-designer)
Ten more calls, behind `ReportDesignerController`. Separate from the two above on purpose: those **run** a report for a viewer and are gated on the report’s own resource, these **edit** the document and are gated on the definitions grid’s resource — merging them would put a viewer’s read path and an author’s write path behind one permission.
| Method & route | Returns | Gate |
| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
| `GET /api/v1/genie/report-designer/definitions/{key}` | The definition’s identity plus its source XML. `DefinitionXml` is `null` for a row stored before the XML was retained. | `View` on `ReportDefinitions` |
| `POST /api/v1/genie/report-designer/definitions/save` | Upserts by the document’s own `Name`, so one call both creates and updates. A parse error is a 4xx carrying the parser’s message. | `Update` on `ReportDefinitions` |
| `GET …/definitions/{key}/versions` | Every version, newest first, with the current one flagged. | `View` on `ReportDefinitions` |
| `GET …/definitions/{key}/versions/{versionHash}` | The document as that version saved it. | `View` on `ReportDefinitions` |
The draft lifecycle. `{key}` addresses the stored report by `Name` or `Slug` — never the draft document’s own name, which the author may have changed:
| Method & route | Returns | Gate |
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
| `GET …/definitions/{key}/draft` | The report’s draft, or `null`. Includes `IsStale`, true when the published document moved since the draft forked. | `View` on `ReportDefinitions` |
| `POST …/definitions/{key}/draft` | Creates or replaces the draft. Not versioned, and **not parsed** — a draft is allowed to hold text the parser would reject. | `Update` on `ReportDefinitions` |
| `DELETE …/definitions/{key}/draft` | Discards the draft. `false` when there was none — idempotent, so a stale grid’s second click is not a 404. | `Update` on `ReportDefinitions` |
| `POST …/definitions/{key}/publish` | Promotes the draft: appends a version, repoints the report, clears the draft. `Unchanged: true` when the draft matched what was live (nothing appended, draft still cleared). A 4xx when the draft will not parse, or when publishing would rename the report onto a name another one holds. | `Update` on `ReportDefinitions` |
| `GET …/definitions/{key}/draft/structure` | The **draft’s** structure, SQL-free. | `Update` on `ReportDefinitions` |
| `POST …/definitions/{key}/draft/data` | The **draft’s** datasets. | `Update` on `ReportDefinitions` |
The last two are `Update`, not `View`, and they match each other deliberately. Running an unpublished document is more than reading one; and giving structure the weaker gate would let a client render a layout it can never fill. They exist as their own endpoints rather than a `?draft=1` flag on `GET /reports/{key}` because that call is **ungated beyond authentication** — right for a published layout, a disclosure for an author’s work in progress.
Row-level security in a draft preview resolves against the **stored report’s** resource name, so renaming a draft is not a way out of a row filter.
```jsonc
// POST /api/v1/genie/report-designer/definitions/save
{
"DefinitionXml": " … ",
"Notes": "Added the supplier-mix chart"
}
```
```jsonc
// → SuccessDataResult
{
"success": true,
"data": {
"Name": "InventoryOverview",
"Slug": "inventory-overview",
"Created": false,
// True when the incoming text matched what was stored: nothing written, no version appended.
"Unchanged": false,
"VersionHash": "4f9c2a"
}
}
```
Restoring a version is a `save` with the text `…/versions/{versionHash}` returns — it appends a new version rather than rewriting history, which is why there is no restore endpoint. See [The report designer](/reports/designer/).
## Files, jobs & scripting
[Section titled “Files, jobs & scripting”](#files-jobs--scripting)
| Base route | Controller | Covers |
| ---------------------------------------------------- | -------------------------- | ---------------------------------------------- |
| `/api/v1/genie/hosted-services` | `HostedServicesController` | Start/stop/inspect background hosted services. |
| `/api/v1/hangfire` | `HangfireController` | Hangfire job info. |
| `/api/v1/genie/execute-script` | `ExecuteScriptController` | Execute a registered server script. |
| `/api/v1/genie/object/list`, `/api/v1/genie/scripts` | `ModelCatalogController` | Read the model catalog (see below). |
| `/api/v1/genie/handlers` | `GenieHandlersController` | Pluggable export/import handlers. |
| (files) | `FileController` | File download/serving. |
### The model catalog (System role)
[Section titled “The model catalog (System role)”](#the-model-catalog-system-role)
The read counterpart of `execute-script` — deploy writes the catalog, these read it back. All three are gated to the **System** role, because script bodies contain the authored SQL that the metadata endpoints deliberately never expose:
| Endpoint | Returns |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/v1/genie/object/list` | Every deployed object (ViewStore): `Name`, `Slug`, `Label`, `ViewType`, `VersionHash`. |
| `GET /api/v1/genie/scripts?type=` | Executed scripts, **latest per name**, optionally filtered by type (`ObjectView`/`Entity`/`Navbar`/`StoredProcedure`/`Rbac`). |
| `GET /api/v1/genie/scripts/{name}` | One script’s latest execution **including its verbatim authored body** (matched by `Name` or `FileName`). |
This is what lets an external client (GenieClient CLI/MCP) enumerate a deployment with no local checkout, export the deployed XML, and diff a local model file against the source that is actually live (`VersionHash` is the MD5 of the deployed body, so an identical hash ends the question).
See [Background jobs & hosted services](/platform/background-jobs/).
## The AI assistant
[Section titled “The AI assistant”](#the-ai-assistant)
The assistant chat is delivered over a SignalR hub rather than a REST controller, with a supporting natural-language-query endpoint. See [AI Assistant](/assistant/overview/).
Discovering payloads
The engine ships OpenAPI/Swagger annotations (`[SwaggerTag]`, `[SwaggerOperation]`). When running a host with OpenAPI enabled, the generated spec is the authoritative, always-current request/response reference for these controllers.
# API overview
> Base URL, authentication, the response envelope, and the structure-vs-data model.
The engine exposes a JSON HTTP API. Everything below is served by ASP.NET Core controllers under `Genie.Engine/Api/**`. There is **no HTML** — the React UI owns all rendering.
## Base URL & versioning
[Section titled “Base URL & versioning”](#base-url--versioning)
All engine routes are versioned under `api/v1`. The main object surface is:
```plaintext
/api/v1/genie/object/...
```
Other areas (auth, navbar, search, reports, notifications, wizard, workflow, …) live under their own `api/v1/...` prefixes — see [Other endpoints](/api-reference/other-endpoints/).
## Authentication
[Section titled “Authentication”](#authentication)
Every controller is `[Authorize]` unless noted. Authenticate with a short-lived **JWT bearer** token obtained from the auth endpoints, sent as:
```plaintext
Authorization: Bearer
```
Tokens are RS256-signed and refreshable. See [Identity](/security/identity/) for the login/refresh flow and MFA. A `X-TimeZone` header (IANA zone) is honoured for session-local date handling; see [Timezone handling](/platform/timezones/).
## The response envelope
[Section titled “The response envelope”](#the-response-envelope)
Successful responses are wrapped in a `SuccessDataResult`:
```json
{
"success": true,
"data": { /* the T payload */ }
}
```
Errors are shaped by a global exception handler into a consistent envelope:
```json
{
"success": false,
"error": "Human-readable message (for expected 4xx).",
"traceId": "00-…"
}
```
A **field validation** failure (400) additionally carries `fieldErrors` — field name → message — so a client can highlight and focus the offending field instead of only showing the joined text (see [Validation](/model-authoring/editor-fields/#validation)):
```json
{
"success": false,
"error": "This code is already used by another product.",
"traceId": "00-…",
"fieldErrors": { "Code": "This code is already used by another product." }
}
```
The HTTP status is mapped from the thrown exception type: `UnauthorizedException` → 401, `NotFoundException` → 404, `ArgumentException` / `RuntimeException` / `GenieException` / `FieldValidationException` / `InvalidOperationException` → 400, `ConflictException` → 409, otherwise 500. Whether 5xx responses expose the real message is config-driven (`Genie:Errors` — on in dev, off in prod). See [Errors & exception handling](/integration/errors/).
## The structure-vs-data model
[Section titled “The structure-vs-data model”](#the-structure-vs-data-model)
The single most important thing to understand about the object API: the client fetches the **schema once**, then loads **data** as needed.
| Concern | Endpoint | Gated by |
| ----------------------------------------------- | ------------------------------------ | ------------------------ |
| **Structure** (schema, columns, fields, layout) | `GET /object/{name}/metadata` | authentication only |
| **Grid data** (rows + paging) | `POST /object/{name}/table` | View |
| **Record values** (form/view) | `POST /object/{name}/form` · `/view` | View / Update |
| **Mutations** | `POST /object/{submit,delete-row,…}` | Create / Update / Delete |
The metadata endpoint is SQL-free, static and cacheable, and never returns record values. Data endpoints run the view’s SQL and are gated by the RBAC **data-disclosure gate**. Any `permissions` / `canSubmit` fields in a response are **UI hints only** — the server re-validates on every operation. See [The disclosure gate](/security/disclosure-gate/).
Full object endpoint reference: [Object endpoints](/api-reference/object-endpoints/).
# Configuration & providers
> Selecting and configuring an Assistant provider, wiring the backend and frontend, and tuning persistence, titles and error masking.
The Assistant is configured entirely through the root **`Assistant`** configuration section (bound to `AssistantOptions`) plus two host calls on the backend and one module flag on the frontend. This page covers the providers, the wiring, and the runtime behaviour you can tune.
For what the feature does, see the [AI Assistant overview](/assistant/overview/). For DB-enforced tenant isolation, see [Tenant isolation & SQL hardening](/assistant/sql-hardening/).
## Models
[Section titled “Models”](#models)
A chat picks the model that answers it, from the list under **`Genie:Assistant:Providers`**:
```jsonc
"Genie": {
"Assistant": {
"Providers": [
{ "Name": "DeepSeek R1", "Provider": "DeepSeek", "Endpoint": "https://api.deepseek.com/v1",
"Model": "deepseek-reasoner", "MaxTokens": 10000, "IsDefault": true },
{ "Name": "Local Llama", "Provider": "Ollama", "Endpoint": "http://localhost:11434",
"Model": "codellama:7b" }
]
}
}
```
An **array**, so the order is the order the model picker lists — and the index an out-of-band [API key](#api-keys--keep-them-out-of-source) targets. `Name` is free text: it is what the picker shows and what a chat remembers, so it can read like a name rather than a model id. It must be unique; `IsDefault` marks the one a new chat starts on; `Enabled: false` takes a model out of service without deleting the entry that documents it.
Why per-chat rather than per-deployment: the useful choice differs by question. A reasoning model is worth its latency for a report someone is about to publish, and wasted on “how many suppliers do we have”.
Every model is reached through **`Microsoft.Extensions.AI`’s `IChatClient`**. The built-in kinds are all served by the OpenAI client pointed at the kind’s endpoint — DeepSeek, Ollama and Gemini speak OpenAI Chat Completions — and a host can bring any other provider as a `ChatClient` entry ([below](#bringing-your-own-ichatclient)). The kinds, and what goes with each:
| `Provider` | Default `Endpoint` (optional) | Example `Model` | API key |
| -------------------- | --------------------------------------------------------- | ------------------------------------ | ---------------------------- |
| **OpenAI** | `https://api.openai.com/v1` | `gpt-4o-mini`, `gpt-5-mini` | required |
| **DeepSeek** | `https://api.deepseek.com/v1` | `deepseek-chat`, `deepseek-reasoner` | required |
| **Gemini** | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.5-flash` | required |
| **Ollama** | `http://localhost:11434` (served under `/v1`) | `qwen3`, `llama3.1:8b` | not required (local) |
| **OpenAICompatible** | none — **required** | whatever the server serves | optional |
| **ChatClient** | not used | optional (sent as `ModelId`) | the host’s client handles it |
`Endpoint` is the **API root**. Leave it out and a hosted kind uses its own service. A trailing `/chat/completions` is accepted and stripped, so entries written with the full completions URL keep working; Ollama’s root gets `/v1` added, and Gemini’s native root (`…/v1beta`) is pointed at its OpenAI-compatible surface (`…/v1beta/openai`). Gemini is called through that surface on purpose: its native API took the key in the query string, where proxies and HTTP logs record it — the compatible surface takes it as a bearer header.
Every entry is validated at **startup** — a missing or duplicate `Name`, or a `Provider` that is not one of the kinds above, fails there rather than on the first question someone asks.
Per-entry settings beyond those: `MaxTokens`, `Temperature`, `TopP`, `TimeoutSeconds`, `StreamIdleTimeoutSeconds`, `EnableThinking`, `ReasoningEffort`, `OrganizationId`.
What is the same for **every** kind, because it is decided once in the provider layer rather than per provider:
* **Retries.** A `408`/`429`/`5xx` or a transport failure is retried twice, with backoff, before the turn fails. A rejected request (`400`/`401`/`404`) is never retried — it is refused identically every time — and is reported with the status, the URL dialled and the server’s own explanation (with a key-length hint on a `401`, never the key).
* **Running out of tokens.** An answer cut off at `MaxTokens` triggers the assistant’s compaction retry on every kind. (Before, only DeepSeek and OpenAI’s reasoning path noticed.)
* **Reasoning never becomes the reply** (see the thinking notes below).
* **A streamed answer that goes silent** is abandoned after `StreamIdleTimeoutSeconds`.
* **Keys stay out of errors and logs**: any echo of the key in a provider’s error body is redacted before it reaches an exception, the [turn trace](#tracing-a-turn) or the log.
* **OpenTelemetry**: each model call is an `Activity` on the `Genie.Assistant` source (a no-op unless a listener is attached), recorded **without** message content — the turn trace remains the only place chat text is logged.
Newer OpenAI models are adapted to automatically
The GPT-5 and o-series families reject settings the older ones accept — `max_tokens` must be `max_completion_tokens`, and neither `temperature` nor `top_p` is supported at all. You do not configure this.
Those families are **known up front**, so a `gpt-5-*` or `o*` model on OpenAI’s own endpoint sends the right request first time, with no probe. Anything else — a new family, or an OpenAI-compatible server with its own rules — is learned from the server’s own rejection: the provider corrects itself, retries, and remembers the answer for that model on that endpoint, so a correction costs one round trip once rather than one per turn.
The assumption is only made for OpenAI’s own service. A self-hosted server may answer to the name `gpt-5-mini` while accepting the ordinary parameters, and a guess is not evidence. Every other server is sent `max_tokens`, the name llama.cpp, LM Studio, DeepSeek and Ollama understand.
It only ever drops parameters Genie chose to send, and only when the error names that parameter with an `unsupported_parameter` / `unsupported_value` code. A rejection about `model` or `messages` teaches it nothing and is reported as-is, because those are the request rather than a tuning knob.
## Running a local model (llama.cpp, Ollama, vLLM, LM Studio)
[Section titled “Running a local model (llama.cpp, Ollama, vLLM, LM Studio)”](#running-a-local-model-llamacpp-ollama-vllm-lm-studio)
Ollama has its own kind; any other server that exposes the OpenAI API (`llama-server`, vLLM, LM Studio, a proxy) is an **`OpenAICompatible`** entry with its API root as `Endpoint`:
```jsonc
{ "Name": "Local Qwen", "Provider": "OpenAICompatible",
"Endpoint": "http://my-host:18437/v1", "Model": "qwen2.5-coder-7b" }
```
No `ApiKey` is needed for either. Beyond the endpoint:
* **Size the options to the server’s context window**, not to a hosted model’s. For a `--ctx-size 8192` server, the 10000-token `MaxTokens` default is larger than the entire window; llama.cpp will reject or clamp it. The prompt and the reply share that budget:
| Prompt component | Approx. tokens |
| ------------------------------------------ | -------------- |
| Built-in rules and formatting instructions | \~1,400 |
| Tool definitions (5 information tools) | \~600 |
| Schema context at `SchemaTableLimit: 5` | \~600 |
| User context, app-docs index | \~400 |
That leaves roughly 5,000 for history, the question and the answer. Lower `SchemaTableLimit` and `ConversationHistoryLimit` to buy headroom; the tables you drop stay reachable via `get_table_schema`.
* **CPU inference is minutes, not seconds.** Token generation is memory-bandwidth bound, so a 7B Q4 model runs at single-digit tokens/second whatever the core count. With `EnableThinking: false` the call does not stream, so raise `TimeoutSeconds` to cover a whole response body, and raise `RequestTimeoutSeconds` either way. Turn the [trace](#tracing-a-turn) on to see where the time actually goes.
* **Thinking models stream their reasoning.** With `EnableThinking` on (the default), Ollama and `OpenAICompatible` entries stream, and a reasoning model’s `reasoning` / `reasoning_content` field is shown in the **Thinking** panel — never as the reply.
Report authoring needs a large context window
The report tools put the whole report definition in the prompt and ask the model to return a whole revised one. A real report is 13–14 KB — roughly 4,000 tokens each way — so on an 8k-context server the input alone leaves no room to answer. Small local models can run the `information` category comfortably; report authoring needs a model with a large window.
Raising `--ctx-size` on the server is what buys the headroom — the window is the server’s, and Genie’s options only decide how much of it the prompt spends. A small report (1–2 KB) is workable at 8k; the 13 KB ones are not.
DeepSeek thinking mode
`EnableThinking` (default `true`) only applies to **`deepseek-reasoner`**: it streams a chain-of-thought (`ReasoningEffort` `"high"` by default, or `"max"`) that the UI shows as a live, collapsible **Thinking** block. On `deepseek-chat` there is no chain-of-thought, so the assistant uses the plain non-streaming completion regardless of `EnableThinking`.
Any model name DeepSeek serves is accepted — a new model needs no engine release. (An earlier allowlist of three names existed only to dodge an empty, never-ending stream; the `StreamIdleTimeoutSeconds` deadline covers that for every kind now.)
The chain-of-thought **never becomes the reply**. A reasoner can end a pass having written a full set of working notes and no answer at all; the notes stay behind the Thinking toggle and the model is asked once to write the answer it skipped. If it still writes nothing, the turn reports that plainly — what it must never do is hand the user the model’s notes to itself.
GPT-5 / o-series thinking mode
`EnableThinking` (default `true`) also applies to OpenAI’s reasoning families — `gpt-5*`, `o1`, `o3`, `o4` — but **only against OpenAI’s own hosted API**. Chat Completions (`/v1/chat/completions`) never exposes a reasoning trace for these models at any `ReasoningEffort`. The only place OpenAI streams anything about a model’s thinking is the separate **Responses API** (`/v1/responses`), so a reasoning-family model on `api.openai.com` is routed there instead, and its reasoning summary is streamed through the same Thinking panel DeepSeek uses. The request asks OpenAI to keep **no** server-side copy of the turn (`store: false`): Genie persists the conversation itself.
What streams is a **summary** the model writes about its own reasoning, not the underlying chain-of-thought — OpenAI does not return that from any endpoint. `gpt-5-mini` in particular has been observed to return an empty summary on straightforward turns; that is the model choosing to say nothing, and the Thinking panel is simply empty for that turn.
A self-hosted or proxied server answering to a reasoning-family model name (a local llama.cpp claiming `gpt-5-mini`, say) is **not** routed to `/v1/responses` — it is not guaranteed to implement that API at all. Only OpenAI’s own endpoint (a blank `Endpoint`, or one containing `api.openai.com`) takes this path; everything else, and `EnableThinking: false`, uses Chat Completions.
## Bringing your own `IChatClient`
[Section titled “Bringing your own IChatClient”](#bringing-your-own-ichatclient)
Any provider with a `Microsoft.Extensions.AI` implementation — Azure OpenAI / Foundry, Amazon Bedrock, Anthropic’s C# SDK, Google’s `Google.GenAI`, OllamaSharp, or your own — can back a model in the picker without engine code. Declare the entry with `Provider: "ChatClient"`, and register the client under the **entry’s `Name`** after `AddGenieAssistant` / `AddGenieApp`:
```jsonc
{ "Name": "Company Claude", "Provider": "ChatClient", "Model": "claude-sonnet-5", "MaxTokens": 8000 }
```
```csharp
// Program.cs — after AddGenieAssistant / AddGenieApp:
builder.Services.AddKeyedChatClient("Company Claude", sp => /* your IChatClient */);
```
The entry keeps everything configuration gives the built-in kinds — its place in the picker, `IsDefault`, `Enabled`, `MaxTokens` / `Temperature` / `TopP` (sent as `ChatOptions`), `Model` (sent as `ModelId` when set), `ReasoningEffort` (when it names a `ReasoningEffort` level) and the idle deadline — and the same shared rules: truncation, reasoning kept out of the reply, and the turn trace. With `EnableThinking` on it is called streaming, and any `TextReasoningContent` it yields goes to the Thinking panel. The host owns the client’s lifetime (and its own timeouts and retries); the engine never disposes it. An entry whose client is not registered fails the turn with a message naming the `AddKeyedChatClient` call to make.
## Backend wiring
[Section titled “Backend wiring”](#backend-wiring)
The umbrella `AddGenieApp` already registers the Assistant module and maps the hub. A host that composes the engine directly adds two calls:
Program.cs
```csharp
services.AddGenieAssistant(builder.Configuration); // provider + MCP tools + services
// …
app.MapAssistantHubs(); // the /hubs/assistant-chat SignalR hub
```
Both entry points also accept **code-first overrides** (config-first + in-code override, code wins — including registration-time values like `Provider` and `ChatWidgetEnabled`):
```csharp
// Direct composition:
services.AddGenieAssistant(builder.Configuration, o =>
{
o.Provider = "OpenAI";
o.ChatWidgetEnabled = true;
o.WelcomeChips = ["Which products are low on stock?"];
});
// Umbrella AddGenieApp — via the fluent GenieBuilder:
builder.Services.AddGenieApp(genie => genie
.LoadFromConfiguration(builder.Configuration)
.ConfigureAssistant(o => o.Model = "deepseek-chat"));
```
`AddGenieAssistant` (`ServiceCollectionExtensions`) binds `AssistantOptions`, registers the one HTTP client every built-in kind sends through (`GenieAssistant`), the core services (`SchemaContextBuilder`, `NaturalLanguageQueryService`, `ConversationService`, `ChatHistoryService`, `AppDocsService`, `AssistantQueryExecutor`, …), the four MCP tools, the `McpOrchestratorService`, the selected provider, and the **`AssistantChat` authorization policy** that gates the hub. The policy authenticates with **Cookie or JWT Bearer** (the same scheme pair as the notification hub’s `ApiPolicy`), so the SignalR `?access_token=` authenticates the connection and an unauthenticated negotiate gets a `401` — not a cookie login redirect.
## Configuration reference
[Section titled “Configuration reference”](#configuration-reference)
Configuration lives in **`Genie:Assistant`**:
appsettings.json
```jsonc
"Genie": {
"Assistant": {
"Providers": [ // the models the picker offers — see Models above
{ "Name": "GPT-4o mini", "Provider": "OpenAI",
"Endpoint": "https://api.openai.com/v1", "Model": "gpt-4o-mini", "IsDefault": true }
],
"ConversationMode": true, // keep chat context across turns
"AutoGenerateTitle": true, // AI-summarise the first message into a chat title
"DocsPath": "AssistantDocs", // optional: a dir of *.md help docs (merged over baseline)
"ChatWidgetEnabled": true, // gates the AssistantChat auth policy / hub
"ChatWidgetEnabledForRoles": "*", // "*" or a list/CSV of role names, e.g. ["Admin","Analyst"]
"RequestTimeoutSeconds": 120, // overall deadline for one turn (MCP loop + streaming)
"MaxIterations": 5 // tool-calling passes before the assistant must answer
}
}
```
Other notable `AssistantOptions` knobs (all optional, sensible defaults):
* **Per-model** (on a `Providers` entry, not here): `MaxTokens` (10000), `Temperature` (0.1), `TopP` (0.9), and `TimeoutSeconds` (60) — the timeout for a **single** provider HTTP request (connect + headers, and the full body of a non-streaming completion), applied per attempt.
* **`StreamIdleTimeoutSeconds`** (90, per-model) — how long a **streamed** answer may go without delivering a token before the call is abandoned. `TimeoutSeconds` cannot cover this: it ends at the response headers, and an SSE body arrives after them. Observed against DeepSeek on 2026-09-15 — `200 OK`, then a `: keep-alive` comment every 12 seconds and no tokens at all, on both `deepseek-reasoner` and `deepseek-chat`, with a funded account. A keep-alive deliberately does not reset the clock: it proves the connection is alive, which is the one thing not in doubt. The caller is told the provider accepted the request and then sent nothing, and which model did it. Set to `0` to disable.
* **`ReasoningEffort`** (per-model; unset by default) — `minimal`, `low`, `medium` or `high`, sent as `reasoning_effort`. Worth setting on the GPT-5 and o-series families, whose default is `medium` and which pay for it on **every pass** of the tool-calling loop rather than once per answer: measured against GPT-5 Mini, passes ran 20–76 seconds each and an eight-pass turn hit the request deadline with no answer. The trade is smaller than it sounds, because most passes are “read the schema block, emit one tool call” — a formatting decision, not a reasoning problem. Unset sends nothing, and a server that rejects the parameter has it dropped after one round trip, so it is safe on an entry pointed at a proxy or a local model. (A `deepseek-reasoner` entry still sends `high` when this is unset, as it always has.)
* **`MaxIterations`** (5) — how many tool-calling passes one turn may take before the assistant is made to answer with what it has. Raise it for genuinely multi-step work: editing a report can spend a pass reading the draft, one per source it checks, one previewing and one applying, and running out mid-edit leaves the user with an explanation instead of a change.
* **`RequestTimeoutSeconds`** (120) — the **overall** deadline for one assistant turn: the whole multi-iteration MCP tool-calling loop plus the provider’s streamed reasoning read. A streamed SSE body isn’t covered by `TimeoutSeconds` once the response headers arrive, so without this a stalled provider stream would leave the chat stuck on “Thinking” forever; when it elapses the caller is told **“The assistant timed out.”** Set to `0` to disable.
* **`ConversationHistoryLimit`** (3) — how many recent Q\&A pairs (beyond the system prompt) are sent each turn, to control token usage.
* **`UserMemoryMaxEntries`** (0 — **off**) — how many of the user’s recent questions, taken from *their other chats*, are injected into every conversation’s system prompt. A chat’s own history is always sent in full and is unaffected; this is the cross-thread bleed, and it is off because a new chat that already knows what the last one was about is the opposite of what opening one is for. Set a small positive number to turn it on. (Before this it could not be turned off at all: `0` selected the old default of 8 instead of disabling it.)
* **`SchemaTableLimit`** (15) — how many tables are described inline in the system prompt; the rest remain reachable via `get_table_schema`.
* **`ToolResultMaxChars`** (8000) — cap on a single tool result before truncation. A tool can opt out with `IAssistantTool.TruncateResult => false`, and the report tools do: half a report is not a cheaper report, it is one with no closing tag, and the model cannot tell that what it was handed was incomplete. Keep the default for anything row-shaped, where the first N rows still answer the question.
* **`WelcomeMessage`** / **`WelcomeChips`** — the greeting and quick-start suggestion chips for an empty chat, served to the React UI via **`GET api/assistant/config`**. Per-field precedence: the host UI’s `assistant.*` config > these server values > built-in defaults — and there are **no default chips**: when neither the host UI nor the server provides any, no chips render.
Widget gating
`ChatWidgetEnabledForRoles` accepts a JSON array **or** a single `*`/comma-separated string. `*` (or an empty list) allows any authenticated user; otherwise the caller must hold one of the named roles. When `ChatWidgetEnabled` is `false` the chat is off regardless of roles.
The rule is enforced by the `AssistantChat` policy on **both** transports: the hub **and** every conversational REST endpoint (`POST /query`, `POST /generate-sql`, and all `/chats` thread CRUD). Only `GET /config` stays open to any signed-in user, because it is how the UI learns the answer — it reports `ChatEnabled` for the caller, and the panel shows a “not enabled for your account” notice instead of a composer when it is `false`. (Previously the REST endpoints ignored this setting, so a refused hub connection fell back to REST and the assistant kept answering — without its charts.)
### API keys — keep them out of source
[Section titled “API keys — keep them out of source”](#api-keys--keep-them-out-of-source)
ASP.NET Core layers the environment-variable provider **after** the JSON files, and nested keys map via the `__` separator. `Providers` is an array, so a key targets an entry by its **index**:
```powershell
# the FIRST entry in Genie:Assistant:Providers
setx Genie__Assistant__Providers__0__ApiKey "sk-…"
# then restart the shell/IDE so the process inherits it
```
The index is positional: reorder the array and an override follows the position, not the model that used to be there. A source’s connection string works the same way (`Genie__ReportingSources__1__ConnectionString`). Ollama is local and needs no key.
A key addressed by *name* rather than index — `…Providers__DeepSeekR1__ApiKey` — **fails at startup** rather than being ignored. Configuration is a flat key-value store, so that path binds as an entry with a key and no name; refusing to start is the right outcome, because a secret that is silently dropped looks identical to one that worked until the provider rejects the request.
Missing key fails gracefully
The API key is validated lazily on the first provider call — a missing key no longer crashes the request pipeline. The thread and user message are still saved, and the failure flows through the normal role-gated error path (below).
## Tool categories
[Section titled “Tool categories”](#tool-categories)
Every active tool’s name, description and parameter schema is written into the system prompt on **every** provider call. A flat registry therefore grows the prompt with each capability added — and prompt size is what decides whether a smaller local model still emits a well-formed `TOOL_CALL:`. So tools declare a category, and only the categories relevant to what the user is doing are offered:
| Category | Tools | Active |
| ------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `information` | `execute_query`, `get_table_schema`, `get_app_doc`, `request_clarification`, `chart_result` | always |
| `reports` | `report_get_draft`, `report_preview_data`, `report_apply_change` | only in the report designer |
| `wizards` | `wizard_get_definition`, `wizard_apply_change` | only in the wizard designer (gated on `Update` of `WizardDefinitions`) |
| `workflows` | `workflow_get_definition`, `workflow_apply_change` | only in the workflow designer (gated on `Update` of `Wf_DefinitionsView`) |
Five tools on an ordinary page, eight in the report designer, seven in the wizard or workflow designer — never the full set everywhere.
The React shell derives the mode from the current route and sends it with each message; nothing needs configuring. A host tool keeps working unchanged, because `IAssistantTool.Category` is a default interface member returning `information`:
```csharp
public sealed class MyTool : IAssistantTool
{
public string Name => "my_tool";
// Category not declared -> "information", offered on every turn.
}
```
Declare `AssistantToolCategories.Reports`, `Wizards` or `Workflows` to be offered only alongside that designer.
A mode also changes the **instructions**, not just the tool list. The bulk of the system prompt is written for the data path — query the database, answer in business language — and on the designer none of it applies. So report-designer mode appends its own block at the very end of the prompt, naming the open report and stating the rules that override what came before: read the report with `report_get_draft` before saying anything about it, never offer a change as XML in the reply (the user has no way to apply it), and put every change through `report_apply_change` as a complete document. Without that block a smaller model treats a designer request like any other question and simply answers it.
`report_get_draft` returns the **report XML contract** after the document — the elements and attributes a report may use, condensed from [Report authoring](/reports/authoring/). It is carried on the tool result rather than in the prompt because it is reference material, and because it is then fetched once, on the call the prompt already requires before any change. Without it the assistant’s only evidence for what an element accepts is whatever the open report happens to contain, so anything the report does not already use is a guess drawn from ordinary charting-library conventions — `Type` for `Kind`, a `Series` attribute, a `SplitBy` that does not exist — and each wrong guess costs a whole round trip, re-sending the document and waiting on another completion. Three of them is the turn.
Give a designer turn room to finish
An edit is several round trips *by design*: read the draft, preview the query, apply the change, then answer. On a reasoning model each is 15–50 seconds, so the default `RequestTimeoutSeconds` of 120 — sized for a chat — can expire mid-edit and hand the user a timeout instead of an Apply button. A host that enables the designer should raise it, along with `MaxIterations` and the provider’s own `TimeoutSeconds`; `sample/Inventory` uses 300, 8 and 120.
The mode is a hint, not authority
It arrives from the browser, so a caller can claim any mode. Every tool a mode unlocks re-checks the caller’s permissions itself — the report tools each verify `Update` on `ReportDefinitions`, the same gate `ReportDesignerController` applies. The worst a forged mode achieves is surfacing a tool that then refuses. An unrecognised mode falls back to `information` only.
## Which databases a chat reads
[Section titled “Which databases a chat reads”](#which-databases-a-chat-reads)
A chat reads **one or more** of the sources configured under [`Genie:ReportingSources`](/integration/configuration/#geniereportingsources--named-read-only-datasources), picked from the checkbox list under the chat’s title bar. `Default` is the application’s own database, described from Genie’s modeled entities and filtered to the tables the caller may reach through a permitted view; every other source is described by a host-written schema (see below).
The picker only offers sources the caller may actually query — the source name **is** the RBAC resource, so “may I see it in the list” and “may I query it” are the same question. Every name the browser sends is re-validated server-side against what exists, what is enabled, what the caller may view, and how many sources one chat may read at once (four). A name that fails **fails the turn** rather than being dropped: answering from a narrower set than the user believes they ticked would look identical to there being no data.
A turn may span sources; a statement may not
With two sources ticked, the prompt carries both schemas, each labelled with its dialect, and every `execute_query` call names the one source it runs against. Nothing joins across them — they are separate databases on separate servers — so an answer needing both is two queries the model combines itself. The prompt says this explicitly, because a model handed two schemas will otherwise try to join them.
The selection is fixed for the duration of a turn and free to change between them. Fixed within a turn because a source decides both the connection opened and the tenant column rows are filtered on, so a mid-turn switch is a cross-tenant hazard rather than an inconvenience.
A chat remembers its selection, so reopening a thread does not silently answer the next question from a different database than the ones above it in the transcript.
### Report designer
[Section titled “Report designer”](#report-designer)
Opening the assistant on a report pre-ticks **every source that report’s datasets already read**. This is what lets it edit a report at all: an assistant that can only see one database cannot write a dataset for another, and before this it would produce a correct query for the wrong source and be rejected at the last step.
With more than one source in context, the assistant is told to **ask** which source a new dataset should use rather than choose — “total products” against a live table and against a replica are different numbers, and only the user knows which they meant.
### Describing a source
[Section titled “Describing a source”](#describing-a-source)
A source other than `Default` has no modeled entities, so nothing can generate a schema from it — a host writes one. That is an **`ISourceSchemaProvider`**, bound to a source by its own `Source` property:
```csharp
public sealed class AnalyticsSchemaProvider : ISourceSchemaProvider
{
public string Source => "Analytics";
public SourceSchema GetSchema() => new(
[new SourceTableSchema("wh_Inventory_Products", "Product master.
| Column | … |")],
Rules: "ALWAYS read with FINAL and add IsDeleted = 0.");
}
```
```csharp
services.AddSingleton(); // after AddGenieAssistant
```
The binding is a property the compiler sees, not a type name in configuration — so there is nothing to activate by reflection and no path-versus-classname ambiguity. Providers are applied in registration order: a later one overrides an earlier one’s table of the same name and appends its rules, so a small correction can layer over a large description. One that throws is logged and skipped, never allowed to take the assistant down.
Resolved once **per source**, as a singleton. Do not put per-user or per-request logic here; that is what [`IAssistantContextContributor`](#extending-the-assistant-host-seams) is for.
#### From a file
[Section titled “From a file”](#from-a-file)
`SourceSchemaFile.Load(path)` reads the same record from disk, for when the description is documentation a data engineer should edit without a rebuild:
```csharp
public SourceSchema GetSchema() => SourceSchemaFile.Load("Assistant/analytics.schema.xml");
```
It dispatches on what the path is — `.xml`, `.md`, or a directory of either (plus a reserved `_rules.md` taken whole as source-wide rules). Both formats produce the same record, so nothing downstream can tell which was used.
The XML format:
```xml
ALWAYS read with `FROM <table> FINAL` and add `AND IsDeleted = 0`.
```
| Element | Meaning |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `` | Checked against the source the provider serves, so a file copied and re-pointed cannot quietly describe the wrong database. |
| `
` | `Name` is what the assistant writes in SQL. `Summary` is the one line in the prompt’s index; omit it and the first sentence of the body is used. |
| *the table body* | **Opaque** — handed to the model verbatim. There is deliberately no `` element: the half of a description worth having is the part a column grid cannot hold, like the note above about on-hand being a SUM. |
| `` | Rules for the source as a whole, rendered as a list beneath the table index. |
Markdown is the same information with less ceremony — `## TableName` declares a table and everything under it is the body.
What XML earns over it is **load-time validation, naming the file**: a duplicate table name, a `
` with no name, an empty ``, or a root `Source` that disagrees with the provider all throw at load rather than surfacing later as an assistant that cannot find a table.
Name the table the way the target holds it
[Warehousing](/platform/warehousing/) creates `wh_{Schema}_{Table}` — `wh_Inventory_Products`, not `Products` — and keeps no unprefixed copy, so a description using the source table’s name produces SQL against a table the warehouse does not have. Derive it from `WarehousingConventions.TargetTableName` rather than hand-copying the convention into a string.
The catalog is the assistant’s **only** knowledge of a source: nothing probes the database, so a table nobody describes is a table it cannot name. A source with no provider at all is reported as undescribed rather than silently answered from the application database.
### Query rules
[Section titled “Query rules”](#query-rules)
`SourceSchema.Rules` is where the requirements that make a query *correct* rather than merely valid belong — and for a Genie-warehoused ClickHouse target there is one that matters enormously:
```plaintext
- Every table here is a ReplacingMergeTree holding every version of a row, and a soft-deleted record
keeps its last values rather than leaving. ALWAYS read with `FROM
FINAL`, and ALWAYS add
`AND IsDeleted = 0` on a table that has the column.
- Bind parameters the ClickHouse way — `{Name:Type}`, not `@Name`.
```
Without `FINAL` a query sees every historical version of every row; without `IsDeleted = 0` it sees rows someone deleted. Neither fails — they just inflate the answer.
### Declaring a source in code
[Section titled “Declaring a source in code”](#declaring-a-source-in-code)
`Genie:ReportingSources` entries can equally be declared or adjusted through the builder:
```csharp
services.AddGenie(genie => genie
.LoadFromConfiguration(configuration)
.UseSource("Analytics", source =>
{
source.Dialect = SourceDialect.ClickHouse;
source.Connection = "Analytics"; // a ConnectionStrings key, or a literal
source.TenantColumn = "CompanyId";
}));
```
This overload **configures** rather than replaces, so anything `LoadFromConfiguration` bound survives unless you set it. That is deliberate: an overload that rebuilt the entry would silently drop `TenantColumn`, and a source with no tenant column refuses every non-System caller — safe, but baffling if all you meant to change was a password. The three-argument `UseSource(name, dialect, connection)` delegates to it and behaves the same way.
### Access and tenant scoping
[Section titled “Access and tenant scoping”](#access-and-tenant-scoping)
The two protections the primary database provides do not reach a warehouse, so they move into the engine:
* **RBAC** — the source is its own resource. A caller without `View` on a resource named after the source is refused. (Warehouse tables sit behind no view, so the permission filtering that scopes the modeled schema has nothing to bite on.)
* **Tenant** — on the primary database the caller’s company is pushed into a DB session context and row-level-security filters rows whatever SQL the model wrote. ClickHouse has no such policy, so Genie **wraps** the query instead:
```sql
SELECT * FROM ( ) AS genie_scope
WHERE genie_scope."CompanyId" = {genie_tenant:Int64}
```
An outer predicate constrains every row the inner query could produce, through any join, union or grouping. The cost is that the query must project the tenant column — the schema context says so up front, and if it is missing the database says so and the model corrects itself. Failing in that direction is the point: a filter that silently did not apply would be worse than none.
`System` callers cross tenants and are not filtered. `Admin` callers **are** — they administer one company, and RLS scopes them on the primary path too.
Declare how the source is scoped, or non-System users are refused
With neither `TenantColumn` nor `SingleTenant` set, the assistant refuses warehouse queries for everyone but System, naming both settings in the refusal. That is deliberate: the failure of guessing wrong is one company reading another’s data, and nothing downstream would catch it.
### What else follows the source
[Section titled “What else follows the source”](#what-else-follows-the-source)
* **The dialect** — the prompt’s SQL rules, the wrong-dialect backstop and the row cap all switch to the source’s engine, and `execute_query`’s own description names it.
* **Report datasets** — a change the assistant proposes puts every `` on the active source, and `report_apply_change` rejects a document that mixes sources. A hand-authored report may still mix them; the restriction is on what the assistant writes, because it has read one schema.
## Tracing a turn
[Section titled “Tracing a turn”](#tracing-a-turn)
One user message fans out across the hub, the conversation service, the MCP iteration loop and every tool it calls. Reconstructing “what happened to this message” from ordinary log lines means stitching a dozen events together by conversation id and hoping none interleaved with another user’s turn.
`Genie:Assistant:Trace` fixes that from both ends: it collects the whole turn into **one** tagged block, and it stamps a short key on **every other line** the turn writes, so the whole message is one grep.
```jsonc
"Genie": {
"Assistant": {
"Trace": {
"Enabled": true, // default false
"Verbosity": "Io", // Steps | Io | Full
"Live": false, // also echo each step as it happens
"MaxDetailChars": 20000 // per-payload cap; 0 = unlimited
}
}
}
```
### The block
[Section titled “The block”](#the-block)
The event is written under the **`Genie.Assistant.Trace`** source context — route it to its own Serilog sink, or filter it out — carrying `Key`, `TurnId`, `Outcome`, `ConversationId`, `UserId`, `CompanyId` and `TotalMs` as properties, with the block itself in the message. At `Verbosity: "Io"`:
```plaintext
[Assistant] A7C3F1 Answer in 43258.9ms — 42:7 (qwen2.5-coder-7b)
[IN] [A7C3F1]-42:7 User Input — 28 chars, mode=report-designer key=PurchasingPerformance
| show me low stock products
[CTX] [A7C3F1]-42:7 Schema:CacheHit 1589 chars
[ITER] [A7C3F1]-42:7 Iteration 1 (41074 ms)
[LLM-REQ] [A7C3F1]-42:7 2 messages, 5400 chars (40915 ms)
[THINK] [A7C3F1]-42:7 1204 chars
| The user wants products below reorder level…
[LLM-RES] [A7C3F1]-42:7 142 chars
| TOOL_CALL: execute_query
| PARAMS: {"sql":"SELECT …"}
[TOOL-CALL][A7C3F1]-42:7 execute_query
| {"sql":"SELECT …"}
[TOOL-RES][A7C3F1]-42:7 execute_query — 0 rows, 128 chars
| TOOL_RESULT: 0 rows
[OUT] [A7C3F1]-42:7 Answer — 142 chars, 0 block(s)
| No products are currently below their reorder level.
```
| Tag | What it marks |
| ------------- | --------------------------------------------------------------------------------- |
| `[IN]` | The user’s message, verbatim |
| `[CTX]` | Context assembly — schema, history, memory, the active tool set |
| `[SYS]` | The system prompt handed to the model |
| `[ITER]` | One pass of the MCP loop; everything below it nests under the pass that caused it |
| `[LLM-REQ]` | What was sent to the provider this pass |
| `[THINK]` | The model’s chain-of-thought, when it emits one |
| `[LLM-RES]` | The provider’s raw response, before any sanitising |
| `[TOOL-CALL]` | A tool invocation and its parameters |
| `[TOOL-RES]` | What the tool returned, including an `ERROR:` verdict |
| `[BLOCK]` | A rich block (chart, action card) emitted toward the UI |
| `[OUT]` | How the turn ended, and the answer text |
| `[ERR]` | A provider exception, a malformed payload, an unhandled throw |
Lines beginning `|` are verbatim captured text belonging to the step above them, indented so a multi-line prompt cannot be mistaken for a run of sibling steps.
It flushes on **every** exit — an answer, a clarification, a pipeline error, the overall-deadline timeout, a user cancel, a dropped connection, an unhandled exception — because the turns worth reading are usually the ones that failed.
### The key
[Section titled “The key”](#the-key)
`A7C3F1` identifies the **chat session**; `42:7` is the chat id and the user message’s sequence number within it. So `grep A7C3F1` gives you the whole conversation and `grep 'A7C3F1-42:7'` gives you exactly one message.
The key is **derived** from the conversation id rather than generated, so it is the same key every time that conversation is resumed and after a restart — a key copied out of yesterday’s log still finds the live chat.
The same `A7C3F1-42:7` is pushed onto Serilog’s `LogContext` for the duration of the turn, so every line the provider, the schema builder and each tool writes carries it too:
```plaintext
[23:25:17 INF] [A7C3F1-42:7] [Assistant:AppDocs] Loaded 3 app doc(s)
[23:25:18 INF] [A7C3F1-42:7] Sending request to OpenAI — Model: qwen2.5-coder-7b, MessageCount: 2
[23:26:01 INF] [A7C3F1-42:7] [Assistant] A7C3F1 Answer in 43258.9ms — 42:7
[23:26:02 INF] [------] Hangfire server heartbeat
```
Two config details decide whether you see any of this
**The template must render the property.** Serilog only prints what an `outputTemplate` names, so add a `[{Turn}]` column:
```jsonc
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] [{Turn}] {Message:lj}{NewLine}{Exception}"
```
**`FromLogContext` must come first in `Enrich`.** Serilog enrichers use `AddPropertyIfAbsent`, so whichever runs first wins. The `WithProperty` default that produces the `------` placeholder on lines outside a turn has to come *after* it, or it wins every time and the key never appears:
```jsonc
"Enrich": [
"FromLogContext",
{ "Name": "WithProperty", "Args": { "name": "Turn", "value": "------" } }
]
```
Three lines are emitted before the turn’s message row exists and so cannot carry the key: the hub’s receive and conversation-open lines (both `Debug`, and reproduced as the block’s `[IN]` and `[CTX]` steps) and `Created assistant thread …`.
### How much to keep
[Section titled “How much to keep”](#how-much-to-keep)
| Verbosity | Keeps | Size |
| --------- | --------------------------------------------------------------------------------------------- | ---------------- |
| `Steps` | The outline and timings — which pass called which tool, and where the time went | negligible |
| `Io` | **+ the question, the thinking, every provider response, every tool payload, the answer** | \~5 KB a turn |
| `Full` | + the system prompt, the schema, the docs index, and the whole message array resent each pass | 50–100 KB a turn |
`Io` is the day-to-day setting: it answers “what did the model actually say?” without dragging in the prompt and schema that dominate `Full` and barely change between turns. Reach for `Full` when the question is about the prompt itself.
`MaxDetailChars` caps any single captured payload and marks the cut explicitly (`… [truncated 3100 chars]`), so a clipped 13 KB report can never be mistaken for a short one.
`Live: true` additionally echoes each step as a `Debug` line the moment it happens, on top of the block. Use it when a turn is slow or hanging and the question is “where is it right now?” — a 40-second provider call otherwise looks identical to a deadlock until the turn ends.
`Io` and `Full` write your data to the log
The block contains the user’s question, whatever business data the tools returned and — at `Full` — the database schema. It belongs in a developer’s console or a private sink, not a shared production log. That is why tracing is off by default and why `Steps` is the default verbosity once it is on.
When disabled the accumulator records nothing and no event is written, so the pipeline pays nothing. This replaces the former per-iteration Debug transcript, which wrote one event per provider call.
## Extending the assistant (host seams)
[Section titled “Extending the assistant (host seams)”](#extending-the-assistant-host-seams)
The assistant is designed to be extended and overridden from the caller project, entirely through DI — no forking:
* **Business context in the prompt — `IAssistantContextContributor`.** Register any number of implementations; each renders as a titled `=== {Title} ===` section in the system prompt (after the user-context block, before the app-docs index), ordered by `Priority` (lower first). A contributor that throws is logged and **skipped** — it never fails the turn. Contributors run on *every* assistant turn, so keep sections short and cache expensive queries:
```csharp
public sealed class StockSummaryContextContributor(MyContext db, IMemoryCache cache)
: IAssistantContextContributor
{
public string Title => "INVENTORY SNAPSHOT";
public async Task BuildContextAsync(AssistantUserContext user, CancellationToken ct) =>
await cache.GetOrCreateAsync("stock-summary", async e =>
{
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
return $"Current totals: {await db.Products.CountAsync(ct)} products.";
});
}
// Program.cs — after AddGenieAssistant / AddGenieApp:
services.AddScoped();
```
* **Custom / replacement tools — `IAssistantTool`.** `services.AddScoped()` adds a tool to the MCP loop (its `Name`/`Description`/`ParameterSchema` are injected into the prompt automatically). Tool names are case-insensitive and **the last registration wins**, so a host tool named `execute_query` replaces the built-in.
* **Replacing the provider — `IAssistantProvider`.** Register your own implementation *after* `AddGenieAssistant` and single-service resolution takes the last registration: `services.AddScoped();` — the whole pipeline (orchestrator, titles, SQL generation) flows through it.
The [Inventory sample](/getting-started/quickstart/) ships a working contributor (`StockSummaryContextContributor`) plus server-configured welcome chips.
## Frontend `createGenieApp` module
[Section titled “Frontend createGenieApp module”](#frontend-creategenieapp-module)
Mount the assistant by enabling the module. It renders app-wide over every route — an **AI icon in the header** (left of the notification bell) opening a themed **bottom-right** panel. An optional top-level `assistant` block overrides the panel content:
```tsx
createGenieApp({
apiBase: "/api/v1",
modules: { assistant: true },
assistant: {
title: "Inventory Copilot", // header title
subtitle: "Online", // status line
welcome: "Hi! Ask me anything.", // empty-state greeting
suggestions: ["Low stock?"], // quick-start chips ([] renders none, beating server chips)
placeholder: "Ask Copilot…", // composer placeholder
},
});
```
`welcome` and `suggestions` fall back to the server’s `Genie:Assistant:WelcomeMessage` / `Genie:Assistant:WelcomeChips` (fetched once from `GET api/assistant/config` when the panel first opens), then to the built-in greeting — with **no built-in chips**.
The panel is multi-conversation: a conversations list (open / rename / delete / new, plus multi-select bulk delete) and the conversation view — user/assistant bubbles as sanitised Markdown (GFM tables, lists, code) via `marked` + DOMPurify, a live **Thinking** disclosure, a collapsible **SQL** block and result-table preview for data answers, suggestion chips, a `Shift+Enter` composer, and a **stop** control while a reply is in flight. It is accent-/theme-aware.
The UI talks to the backend over `AssistantChatHub` when connected and falls back to REST (`api/assistant`: `GET /config` for the welcome content, `POST /query`, thread CRUD `GET/POST /chats`, `GET/PATCH/DELETE /chats/{id}`, `POST /chats/delete` for bulk delete) otherwise — clarification pills and inline charts work on both transports. `GenieAssistant` and `GenieAssistantButton` are also exported for hosts that want to place or configure them manually (custom `title`, `subtitle`, `suggestions`, `welcome`). See [Frontend configuration](/integration/frontend/).
## Persistence, titles and error masking
[Section titled “Persistence, titles and error masking”](#persistence-titles-and-error-masking)
* **Persistence.** Conversations live in the database (schema `Genie`, tables `AssistantChat` + `AssistantChatMessage`), owned per user (`UserId` + `CompanyId`). Non-admins only see their own threads. A thread is saved only once its first reply succeeds; a failed first turn is rolled back. **Host apps must add the migration** for the two tables (`dotnet ef migrations add AddAssistantChat -c YourContext`), and again after an engine upgrade that adds a column to them — the nullable `AssistantChatMessage.ProviderName`, which records the model that produced each reply so the UI can label it, arrived that way.
* **Titles.** New threads show a truncated placeholder immediately. With `AutoGenerateTitle` on (default) the provider summarises the first message into a concise title after the first reply, pushed live to the list (`ConversationRenamed`); on failure the placeholder is kept silently. A manual rename (`PATCH /chats/{id}`) always overrides a generated title.
* **Error masking.** Internal error detail (a missing/invalid key, a raw provider failure) is surfaced only to users holding the **`System`** role (`AssistantErrors.ForUser`). Everyone else sees a generic *“The assistant is temporarily unavailable…”* message. Generated SQL is likewise returned to admins only.
## Where to go next
[Section titled “Where to go next”](#where-to-go-next)
* **[Tenant isolation & SQL hardening](/assistant/sql-hardening/)** — the read-only login + RLS setup that makes tenant scope DB-enforced.
* **[AI Assistant overview](/assistant/overview/)** — the tool-orchestration model and prompt context.
# AI Assistant overview
> The natural-language-to-SQL chat assistant — its MCP tool orchestrator, RBAC-scoped execution, SignalR streaming and DB-backed multi-chat.
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.
Where it lives
Backend: `src/api/Genie.Engine/Features/Assistant/`. Frontend: the `assistant` module of `createGenieApp` (an AI icon in the header opening a bottom-right chat panel). See [Configuration & providers](/assistant/configuration/) to turn it on.
## What it does
[Section titled “What it does”](#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.
* **Charts, in the conversation.** Ask it to show, plot or compare something and the answer comes with a chart under it (see below).
* **Report authoring.** Open the report designer and it can read the report you are editing, preview what a dataset returns, and propose changes you apply with one click — see [Assistant-assisted authoring](/reports/designer/#assistant-assisted-authoring).
## Rich blocks: charts and Apply cards
[Section titled “Rich blocks: charts and Apply cards”](#rich-blocks-charts-and-apply-cards)
Most replies are Markdown. Some carry a **block** rendered underneath the prose:
* **A chart.** Produced by the `chart_result` tool, which runs the `SELECT` itself rather than asking the model to re-emit the rows — cheaper, and it removes the most likely thing for a model to get wrong. It goes through the *same* safety chain as `execute_query`: SELECT-only validation, the row cap, and the tenant-scoped read-only executor. Charting is a way to display data, never a second way to query it. Rendering reuses the report page’s own chart component, so there is no charting dependency in the package and the colours follow your theme and accent. The chart is drawn at the panel’s real width — labels stay the size the stylesheet asks for however narrow it is — and a bar chart of long or numerous category names is turned on its side, where each name gets its own row.
* **An Apply card.** A proposed change with **Apply** and **Dismiss**. Applying is entirely client-side: the card names a handler that the owning page registered, and that page writes to its own in-memory state. The server never applies anything. If the owning page isn’t open the card says so and stays put.
Because a chart needs room, the panel header has an **expand toggle** that widens it, and the panel’s top edge can be **dragged** to change its height (double-click the handle to reset).
The panel can also be **moved**: drag it by its title bar and it stays where you drop it, clamped so it can never end up off-screen. Once it has been moved, a **reset-position** button appears in the header to send it back to its default corner. Position, width and height are per-session choices — they are not persisted, so a reload starts from the default again.
## Attaching context
[Section titled “Attaching context”](#attaching-context)
Above the composer, a **+** button offers whatever the current page can contribute to the conversation. On the report designer that is **Current report**, on the wizard designer **Current wizard**, and on the workflow designer **Current workflow** (not while previewing a running instance); other pages offer nothing and the button is hidden.
Attaching is what tells the server which tools the turn may use, so the chip is not decoration — it is the switch. **Nothing is attached automatically.** Arriving on the report designer changes nothing about the conversation: until you attach the document, the turn gets the information tools and the assistant answers questions. Attach it and the authoring tools appear; remove the chip and they go again.
That is deliberate. Attaching is what turns the assistant from something you ask questions of into something that rewrites your document, and it also puts a multi-kilobyte document within the model’s reach — neither should follow from the route you happen to be on. It is one click, in the **+** menu, and the chip shows you it happened.
One context is attached at a time; picking from the menu replaces what is there, and navigating to a page that no longer offers it clears it rather than carrying it along.
## Answering in the chat while a document is attached
[Section titled “Answering in the chat while a document is attached”](#answering-in-the-chat-while-a-document-is-attached)
The first way to get a chat answer is simply not to attach the report — see above. But while you are working on one you will want it attached, and a question will still come up mid-edit. So: add **“reply in chat”** to the message — or tell it not to change the report, wizard or workflow — and that turn answers in the conversation and proposes nothing, attachment or no attachment: no Apply card, no XML.
This is enforced, not requested. When the message carries that instruction the change-proposing tool is **not offered to the model at all**, so there is nothing for it to call however the rest of the prompt reads. The reading tools stay, because the question may well be about the open document (“summarise this report, just reply in chat”), and so does `chart_result` — a chart is drawn in the conversation and changes nothing, so “plot the last 24 hours, reply in chat” is answered with a picture and a sentence.
Phrasings that switch it on:
| You write | What happens |
| -------------------------------------------------------- | --------------------------------------------------------------- |
| “…, reply in chat” / “answer in the chat” / “chat only” | Answered in the conversation; nothing proposed |
| “don’t change the report, just give me the figures” | Same |
| “show me the trend without changing the report” | Same |
| “don’t change the report **title**, add a chart instead” | **Ordinary authoring** — this asks to change something specific |
That last row is the line the rule is drawn on: the instruction has to end the clause. A request to change one particular thing reads, word for word, like a request to change nothing, so only where the phrase stops decides which it is. And whichever way it goes, the failure is cheap in one direction only — an answer in the chat when you wanted an edit costs one message; an edit you asked nobody to make costs an undo.
Each chat is its own context
A conversation is sent its own history and nothing else. Recalling a user’s recent questions from their *other* chats is a separate, opt-in setting ([`UserMemoryMaxEntries`](/assistant/configuration/), default 0) — so a new chat starts genuinely new, rather than answering as though the previous subject were still on the table.
A chart comes back; an Apply card does not
Reopening a conversation restores its charts — they are stored with the message that carried them, so a thread you come back to still shows the picture you asked for rather than only the sentence underneath it.
Apply cards are not restored, and that difference is the point. A chart records what the data was at a moment that has already passed. An Apply button is a live offer to change a document, and one restored hours later would propose an edit against a draft that has moved on — with nothing on the card to say so.
## One clarification, not a funnel
[Section titled “One clarification, not a funnel”](#one-clarification-not-a-funnel)
The assistant may ask a follow-up question when a request is genuinely ambiguous — but only one, and answering it ends the matter: `request_clarification` is withheld from the turn that carries your answer, so the next thing that happens is a query.
That is enforced rather than asked for, because the rule as a prompt line had a hole in it. “At most one clarifying question per user message” is true of every message individually, and an answer to a clarification *is* a new message — so a model could narrow indefinitely and still be following the rule. It did: one request to plot an availability trend was met with which series, then which technology set, then how many sites, without a row being read, and the turn that finally had everything it needed ran out of time.
Anything still open after one question is assumed rather than asked about, and the answer says what was assumed. Scope, row counts and the choice between near-identical metrics are explicitly named in the prompt as things to decide rather than ask about.
## The MCP tool-orchestration model
[Section titled “The MCP tool-orchestration model”](#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
```plaintext
TOOL_CALL:
PARAMS: {"key": "value"}
```
A reply may carry **up to three** of these when the calls do not depend on each other — they run in the order written and the results come back together, which is what a model expects when it batches two lookups. Anything past three is not run and the model is told which ones, by name.
The orchestrator parses those directives, dispatches each named tool, appends the results as `tool` messages, 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.
```text
┌─ 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”](#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, with 2–4 suggested answers the UI renders as **one-tap pills** (tap to answer) plus an **“Other…”** pill that focuses the composer for a free-text reply. Only the latest clarification is answerable — history renders the pills inert. Returns a sentinel the orchestrator relays back to the user. **Not offered on the turn that answers one**: see below. |
Efficiency by construction
The system prompt already lists columns for the most relevant tables, so the model is instructed to call `execute_query` directly when it can and only reach for `get_table_schema` for tables not already described. Tool results are capped (`ToolResultMaxChars`) to protect the context window.
## RBAC-scoped execution
[Section titled “RBAC-scoped execution”](#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`/`IsSystem` is pushed into a DB session context so **Row-Level Security** filters rows regardless of what SQL the model wrote. See [Tenant isolation & SQL hardening](/assistant/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](/security/overview/).
## SignalR streaming
[Section titled “SignalR streaming”](#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`). The fallback carries the reply’s rich blocks (charts) just as `ReceiveMessage` does, and it is gated by the same `AssistantChat` policy — so it is a fallback for a dropped connection, never a way around `ChatWidgetEnabled`.
## DB-backed multi-chat
[Section titled “DB-backed multi-chat”](#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 `AutoGenerateTitle` is on) the provider summarises the first message into a concise title that updates live; a manual rename always wins.
* **A reply belongs to the thread that asked for it.** Leaving a conversation while it is still being answered — to the thread list, to another chat, or to a new one — no longer carries the “thinking” state along with you, and the answer lands in its own thread rather than whichever one is on screen when it arrives. The thread list marks the conversation still being answered, and opening it shows the pending question with the reply still coming.
* **One reply at a time.** The hub runs one turn per connection, so while any thread is being answered the composer in the others will not send: doing so would cancel the running reply server-side.
* **Each reply names the model that wrote it** (beside its timestamp), stored per message. The model picker stays live for the life of a thread, so a transcript can hold answers from more than one model and each is labelled with its own.
Host migration required
The two chat tables live in the host’s `GenieContext`. A host must add the migration for them (`dotnet ef migrations add AddAssistantChat -c YourContext`), and re-run `migrations add` after an engine upgrade that adds a column — `AssistantChatMessage.ProviderName` (which model answered) arrived that way, and is nullable, so existing rows simply carry no label.
## Prompt context: what the assistant knows
[Section titled “Prompt context: what the assistant knows”](#prompt-context-what-the-assistant-knows)
Every request injects four things into the system prompt, assembled by the orchestrator:
1. **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).
2. **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 via `Genie:Assistant:DocsPath` (host wins on collision).
3. **Host-contributed business context** — any `IAssistantContextContributor` implementations the caller project registers in DI render as titled sections (live business signals, KPIs, domain glossaries…). See [Extending the assistant](/assistant/configuration/#extending-the-assistant-host-seams).
4. **The caller’s roles and full permission map** (from `IPermissionManager`) plus mandatory security rules (SELECT-only, soft-delete filtering, and — for non-admins — a hard `CompanyId` filter requirement).
## Where to go next
[Section titled “Where to go next”](#where-to-go-next)
* **[Configuration & providers](/assistant/configuration/)** — pick and configure a provider, wire the backend and the `createGenieApp` module, and tune persistence/titles/error masking.
* **[Tenant isolation & SQL hardening](/assistant/sql-hardening/)** — make tenant scope enforced by the database, not the prompt.
* **[Security model](/security/overview/)** — how the assistant fits the layered security model.
# Tenant isolation & SQL hardening
> Making the AI assistant's generated SQL tenant-safe by the database — a read-only login plus row-level security — instead of trusting the prompt.
The [Genie Assistant](/assistant/overview/) lets users ask questions in natural language; the model generates SQL and the engine runs it. **By default the generated SQL runs on the app’s connection and tenant scope is only *suggested* to the model in its system prompt.** That is convenient for a trusted or single-tenant demo, but a model that hallucinates, is jailbroken, or is prompt-injected could omit the `CompanyId` filter and read another tenant’s rows.
Prompt text is not an authorization boundary
A rule in the system prompt is a hint, not a guarantee. For real multi-tenant isolation the database — not the model — must enforce which rows come back. This mirrors the engine-wide [disclosure gate](/security/disclosure-gate/) and [multi-tenant scoping](/security/multi-tenancy/).
This guide makes tenant isolation **enforced by the database**, using two layers the engine already supports:
1. **A dedicated, read-only, least-privilege DB login** for the assistant (`Genie:Assistant:ConnectionString`). Even if the SELECT-only validator is bypassed, this login physically cannot write, DDL, or read objects it wasn’t granted.
2. **Row-Level Security (RLS) driven by a DB session context.** Before running each query the engine pushes the caller’s identity into a session context; RLS policies filter every tenant table by it — so the correct rows come back *regardless of what SQL the model wrote*.
Both layers are **opt-in**: with no `Genie:Assistant:ConnectionString` configured the assistant falls back to the app connection and prompt-only scope (unchanged behaviour). Enforcement turns on once you (a) point the assistant at a restricted login and (b) apply the RLS policies below via your host migrations.
## How the engine pushes identity
[Section titled “How the engine pushes identity”](#how-the-engine-pushes-identity)
For every assistant query, `AssistantQueryExecutor` opens the dedicated connection, starts a transaction, and sets a session context **before** running the model’s SQL:
| Key (SQL Server `SESSION_CONTEXT`) | Key (PostgreSQL GUC) | Value |
| ---------------------------------- | -------------------- | ----------------------------------- |
| `genie.CompanyId` | `genie.company_id` | the caller’s company id |
| `genie.UserId` | `genie.user_id` | the caller’s user id |
| `genie.IsSystem` | `genie.is_system` | `1` for System-role users, else `0` |
The context is always set (never skipped), so your RLS predicate can **fail closed**: any connection that did *not* go through the executor has no context and sees nothing. System-role users are granted full access via the explicit `IsSystem` flag — not by an absent value.
Division of labour
The engine sets the context; **you** create the read-only login and the RLS policies (your host owns the physical tables). Apply the DDL below in a host migration or DB bootstrap script.
## SQL Server
[Section titled “SQL Server”](#sql-server)
### 1. Read-only, least-privilege login
[Section titled “1. Read-only, least-privilege login”](#1-read-only-least-privilege-login)
```sql
-- A login/user the assistant uses. It can only SELECT, and only on the tables you grant.
CREATE LOGIN genie_assistant_ro WITH PASSWORD = '';
CREATE USER genie_assistant_ro FOR LOGIN genie_assistant_ro;
-- Least privilege: read only. Grant SELECT on the business schema(s) the assistant may query,
-- and explicitly DENY the rest (e.g. the auth/identity + engine internals).
GRANT SELECT ON SCHEMA::dbo TO genie_assistant_ro;
DENY SELECT ON SCHEMA::Genie TO genie_assistant_ro; -- engine internals (ViewStore, chats, …)
DENY SELECT ON SCHEMA::Identity TO genie_assistant_ro; -- users/roles/tokens
-- (no INSERT/UPDATE/DELETE/EXECUTE granted → writes are impossible)
```
### 2. RLS predicate + policy (apply to every tenant table with a `CompanyId`)
[Section titled “2. RLS predicate + policy (apply to every tenant table with a CompanyId)”](#2-rls-predicate--policy-apply-to-every-tenant-table-with-a-companyid)
```sql
CREATE SCHEMA sec;
GO
-- Returns 1 (row visible) when the caller is System, or the row's company matches the session context.
-- No context set → both SESSION_CONTEXT calls are NULL → returns nothing → fail closed.
CREATE FUNCTION sec.fn_assistant_company_scope(@CompanyId bigint)
RETURNS TABLE WITH SCHEMABINDING
AS RETURN
SELECT 1 AS ok
WHERE TRY_CONVERT(int, SESSION_CONTEXT(N'genie.IsSystem')) = 1
OR TRY_CONVERT(bigint, SESSION_CONTEXT(N'genie.CompanyId')) = @CompanyId;
GO
-- Repeat this block per tenant table:
CREATE SECURITY POLICY sec.AssistantScope_Product
ADD FILTER PREDICATE sec.fn_assistant_company_scope(CompanyId) ON dbo.Product
WITH (STATE = ON);
GO
```
Filter predicates apply to every login
RLS filter predicates apply to **all** logins hitting the table. Scope the policy so it only affects the assistant: either gate the predicate on the login (e.g. `AND ORIGINAL_LOGIN() = 'genie_assistant_ro'`) or, cleaner, keep the assistant login separate and only add policies you’re comfortable applying app-wide. Test against your app’s own queries before enabling `STATE = ON` in production.
## PostgreSQL
[Section titled “PostgreSQL”](#postgresql)
### 1. Read-only, least-privilege role
[Section titled “1. Read-only, least-privilege role”](#1-read-only-least-privilege-role)
```sql
CREATE ROLE genie_assistant_ro LOGIN PASSWORD '';
GRANT USAGE ON SCHEMA public TO genie_assistant_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO genie_assistant_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO genie_assistant_ro;
REVOKE ALL ON SCHEMA "Genie", "Identity" FROM genie_assistant_ro; -- keep internals off-limits
-- no INSERT/UPDATE/DELETE granted → writes are impossible
```
### 2. RLS policy (apply to every tenant table with a `company_id`)
[Section titled “2. RLS policy (apply to every tenant table with a company\_id)”](#2-rls-policy-apply-to-every-tenant-table-with-a-company_id)
```sql
ALTER TABLE public."Product" ENABLE ROW LEVEL SECURITY;
ALTER TABLE public."Product" FORCE ROW LEVEL SECURITY; -- apply even to the table owner
CREATE POLICY assistant_company_scope ON public."Product"
FOR SELECT
USING (
current_setting('genie.is_system', true) = '1'
OR "CompanyId" = NULLIF(current_setting('genie.company_id', true), '')::bigint
);
```
`current_setting(key, true)` returns NULL when the key is unset (`missing_ok`), so with no context the predicate is false → fail closed.
## Wire the assistant to the restricted login
[Section titled “Wire the assistant to the restricted login”](#wire-the-assistant-to-the-restricted-login)
Point the assistant at the read-only connection (kept out of source, like the API key):
appsettings.json
```jsonc
"Genie": {
"Assistant": {
"Provider": "DeepSeek",
// …
"ConnectionString": "" // leave empty to use the app connection (prompt-only scope)
}
}
```
Set it via environment variable so the secret stays out of config (nested keys map via `__`):
```powershell
setx Genie__Assistant__ConnectionString "Server=…;User Id=genie_assistant_ro;Password=…;TrustServerCertificate=True"
```
With it set, `AssistantQueryExecutor` routes every generated query through that login inside a transaction with the session context applied. With it empty, behaviour is unchanged (prompt-only scope). See [Configuration & providers](/assistant/configuration/) for the full options reference.
## What this does and doesn’t cover
[Section titled “What this does and doesn’t cover”](#what-this-does-and-doesnt-cover)
* ✅ **Writes/DDL:** impossible on the read-only login (belt-and-suspenders with the SELECT-only validator in `ExecuteQueryTool`).
* ✅ **Cross-tenant row reads:** blocked by RLS regardless of the model’s SQL, once policies are applied.
* ✅ **System role:** sees all tenants (via the `IsSystem` flag).
* ✅ **Object/column *metadata*:** the schema context the model receives is built from Genie’s modeled metadata (`EntityStore`) and **permission-filtered** — a non-System caller only learns about tables reachable through a view they hold View/List on, and engine/identity internals are never modeled there, so they are never described to the model. (Applies whenever `EntityStore` is populated; a host with no modeled entities falls back to a raw catalog read.)
* ✅ **Catalog enumeration:** the SELECT-only validator also **rejects catalog/metadata reads** — `INFORMATION_SCHEMA`, SQL Server `sys.*`, and PostgreSQL `pg_catalog` / `pg_*`. The permission-filtered schema context is the only table/column source the model may use, so it can’t enumerate names outside the caller’s view boundary.
* ⚠️ You must add an RLS policy for **each** tenant table you want protected; tables without a policy are unfiltered (but still read-only under the restricted login).
## Related
[Section titled “Related”](#related)
* [AI Assistant overview](/assistant/overview/) — the tool orchestrator and RBAC-scoped execution.
* [Multi-tenant company scoping](/security/multi-tenancy/) — how the rest of the engine enforces tenancy in SQL.
* [Security model](/security/overview/) — the layered model this hardening plugs into.
# Components: Table, Form, View
> How GenieTable, GenieForm and GenieView render — fetching structure from metadata and values from the object endpoints, merging them client-side, and generating the DOM ids the server no longer ships.
`GenieTable`, `GenieForm`, and `GenieView` are the three render surfaces for a Genie object. They share one contract: fetch the object’s **structure** once, fetch a page of **rows** or a record’s **values** as needed, merge the two **client-side**, and render. The server ships **no markup** — the React components reproduce the `genie-*` DOM themselves, and even the container/view ids are generated in the browser.
Which of the three renders is decided by the [router](/frontend/routing/) from the route (`/table|form|view/{name}`); a single object can appear as all three (a grid, an editable form, a read-only view).
## The metadata + values merge
[Section titled “The metadata + values merge”](#the-metadata--values-merge)
Every surface follows the same two-request pattern, wired through a hook:
```text
GET /object/{name}/metadata ┐
POST /object/{name}/table ├──▶ builder ──▶ GenieTable / GenieForm / GenieView
POST /object/{name}/form|view ┘
```
* **Structure** is fetched once and **held** (`schemaRef` in the hook; also cached in the API client). Schemas only change on a backend redeploy, so re-navigating re-fetches only data, not structure.
* **Data** re-fetches whenever inputs change — a new page, sort, filter, or record.
* The hook then calls a **builder** (`buildTableModel` / `buildFormModel`) that merges schema + data into the render model the component consumes.
| Surface | Hook | Structure request | Data request | Builder |
| ------------ | --------------------------- | ------------------- | ------------------------- | ----------------- |
| `GenieTable` | `useGenieTable` | `getObjectMetadata` | `getTableData` (`/table`) | `buildTableModel` |
| `GenieForm` | `useGenieForm` | `getObjectMetadata` | `getFormData` (`/form`) | `buildFormModel` |
| `GenieView` | `useGenieForm` (`readOnly`) | `getObjectMetadata` | `getViewData` (`/view`) | `buildFormModel` |
`GenieView` reuses `useGenieForm` with `readOnly: true` — the same schema + values, forced into view mode server-side. See [Object endpoints](/api-reference/object-endpoints/) for the wire contract and [the structure-vs-data split](/security/disclosure-gate/) for why the boundary exists.
### Client-generated ids
[Section titled “Client-generated ids”](#client-generated-ids)
Each hook generates a stable per-instance **`ViewId`** (`crypto.randomUUID()`, falling back to a timestamp) and sends it with every request; the form/table container id is composed from it client-side (e.g. `` `${model.Name}_${model.ViewId}_form` ``). The comment in the source is explicit — “the server no longer ships one.” The id also correlates uploads and scopes the rendered DOM.
## GenieTable — the grid
[Section titled “GenieTable — the grid”](#genietable--the-grid)
`GenieTable` renders a toolbar, an optional filter row, the sortable data grid, row actions, expandable sub-views, and pagination. It’s a controlled component: `useGenieTable` owns page/sort/ filter/search state and passes callbacks in.
**Toolbar** (suppressed entirely when the view sets `DisableControls`):
* **Add New** — the only labelled button, shown only with the `Create` permission.
* **Refresh**, **Export**, **Import**, **Columns** (the column manager: show/hide + reorder). Export/Import are split buttons — a default handler plus any custom [export/import handlers](/model-authoring/import/) as dropdown items, each permission-gated.
* **Filters** toggle → reveals the per-column filter row; **Apply** (enabled only when the draft changed) and **Clear**.
* **Select mode** → row checkboxes for bulk delete (only with the `Delete` permission).
* A table-level **Actions** dropdown for view-level actions.
* Right side: a **quick-search** box (only when the view is `Searchable` — an OR-gated `LIKE` across the view’s searchable columns), the **page-size** select, and a “Showing X to Y of Z” range.
**Grid body:**
* **Sorting** — sortable headers carry independent asc/desc controls; the active column/direction is reflected back from the model.
* **Per-column filters** — booleans render a tri-state select, dates a date input, and text/number a operator select (contains / equal / not-equal / `>` / `<` / `>=` / `<=`) plus a value; edited as a draft and applied on demand.
* **Cells** — text (with an optional client-side display `Expression`), booleans, dates (with optional self-updating relative time), attachment tags (thumbnails + file pills), and **lookup links** that open a linked record read-only in a dialog. Cell classes combine the column’s `StyleClasses`, its evaluated `StyleClassesExpression`, and the host’s optional `cellClass` resolver.
* **Row actions** — a per-row menu. A `Link` action navigates (SPA or new tab) after `{Column}` token substitution; a `Confirm` action prompts first; a `Prompt` action collects inputs in a modal; others POST to `/object/execute-row-action`. Buttons are coloured by a `Color` value mapped to a [`gx-{key}`](/frontend/theming/#the-named-gx--colour-vocabulary) class (row actions fall back to `gx-info`; the standard Edit is `gx-primary`, Delete `gx-danger`).
* **Sub-views** — a row expands to a full-width panel hosting a nested table or form (`SubViewPanel`), scoped to the parent row via `Parent__{Column}` parameters. See [Sub-views](/model-authoring/sub-views/).
**Pagination** is windowed (first/last always shown, ±1 around the current page, ellipses between), and hidden when there’s a single page.
## GenieForm — the editable form
[Section titled “GenieForm — the editable form”](#genieform--the-editable-form)
`GenieForm` renders the record’s **layout tree** (`GenieFormLayout`) and Submit/Cancel actions.
* **Layout** — the builder produces a tree of nodes the layout renderer walks: **section** (`