AI Chat stores everything inside your application: structured data in your App's database via OrmLite, and files under App_Data. There is no external service holding your conversation history.
Database tables​
Tables are created on startup when AutoInitSchema is true (the default), using the host's IDbConnectionFactory - or a named connection if you'd rather keep chat data separate:
services.AddPlugin(new ChatFeature {
NamedConnection = "chat",
AutoInitSchema = true,
});
ChatThread​
One conversation. Complex fields are stored as raw JSON strings so the wire shape matches the OpenAI format exactly, and are only parsed at the DTO boundary.
| Column | Notes |
|---|---|
Id |
Auto-increment |
User |
Data partition key - the authenticated username, or default |
CreatedAt, UpdatedAt |
Indexed |
Title, SystemPrompt, Model |
|
ModelInfo, Modalities, Args |
JSON |
Messages |
JSON - the durable conversation |
StreamingMessage |
JSON - in-flight assistant message while streaming |
Tools, ToolHistory |
JSON |
Cost, InputTokens, OutputTokens, Stats |
Thread rollup |
Provider, ProviderModel |
|
StartedAt, CompletedAt, Status |
|
Metadata |
JSON |
StreamingMessage is deliberately separate from Messages: a failed or abandoned stream can never damage the durable conversation, and checkpointing writes one small column rather than rewriting the whole thread.
ChatRequest​
Per-completion accounting behind the Analytics dashboards - user, thread, model, provider, duration, token counts, prices, cost, finish reason, and Error/StackTrace when a completion fails.
ChatMedia​
The generated and uploaded media catalog - name, type, prompt, model, cost, seed, dimensions, size, duration, aspect ratio, content hash, reactions, caption, tags, rating and publish state. Written by the gallery extension from a cache-saved filter. See Voice & Media.
Gemini tables​
The gemini extension owns its own document catalog tables, created the same way. See Gemini File Search.
Querying​
They're ordinary OrmLite tables:
using var db = dbFactory.Open();
var recent = db.Select<ChatThread>(db.From<ChatThread>()
.Where(x => x.User == userName)
.OrderByDescending(x => x.UpdatedAt)
.Take(20));
AdminQueryChatRequests additionally exposes ChatRequest as an AutoQuery API for admins.
File storage​
Rooted at App_Data/chat by default, overridable with AppDataPath:
Per-user layout​
With RequireAuth = false everything runs as the default user, so all of the above lives under user/default/.
Path resolution is guarded: any relative path that would escape App_Data/chat throws UnauthorizedAccessException.
The content-addressed cache​
Attachments, generated images and audio, and Gemini document uploads are all hashed with SHA-256 and stored once under cache/{first 2 chars}/{sha256}.{ext}. The same file uploaded twice costs one copy.
Cache writes fire the cache_saved filters, which is how the gallery records media and how an App can hook uploads:
ctx.RegisterCacheSavedFilter(saved => {
Log.LogInformation("Cached {Url} ({Size} bytes)", saved.Url, saved.Size);
});
Cached files are served at {RoutePrefix}/~cache/{path} to authenticated users.
Seeded config files​
llms.json, providers.json and providers-extra.json are seeded from embedded defaults on first run and then belong to you - edit them, check them into source control, or bypass them entirely by setting ChatFeature.Config in code. See Providers & Models.
PDF storage​
PdfFeature uses a separate root, App_Data/pdf by default:
See Rendering PDFs.
Backup and deployment​
Back up together:
- Your App's database (threads, requests, media, Gemini catalog)
App_Data/chat- config, cache and every user's workspaceApp_Data/pdf- including.published.jsonand.versions
A database backup on its own is not sufficient: media rows reference cache files by hash, and Gemini document rows reference cached bytes.
Deployment notes:
App_Datamust be on durable storage. On an ephemeral filesystem, mount a volume or setAppDataPathto one.- In a multi-instance deployment,
App_Data/chatandApp_Data/pdfneed to be a shared volume - user workspaces, the cache and published templates are all filesystem state. - Pin the Typst version and deploy the same fonts used during template validation.
- Consider a disk quota on
App_Data/chat/userso a runaway Agent can't fill the disk.
Retention​
AI Chat doesn't expire data on your behalf. Threads, requests and media persist until deleted, which suits audit requirements but means retention policy is yours to implement:
// example: delete threads untouched for a year
using var db = dbFactory.Open();
var cutoff = DateTime.UtcNow.AddYears(-1);
var ids = db.Column<long>(db.From<ChatThread>()
.Where(x => x.UpdatedAt < cutoff).Select(x => x.Id));
db.Delete<ChatRequest>(x => Sql.In(x.ThreadId, ids));
db.Delete<ChatThread>(x => Sql.In(x.Id, ids));
Deleting a user's folder under App_Data/chat/user/{user} removes their projects, profiles, skills, PDF workspace and preferences.