A lean, multi-tenant chat frontend for Cortex instances. Gives end users a clean "Ask AI" interface to query their knowledge base, with admin tools for user management, group-scoped collection access, and document uploads.
Built with Next.js, React, and Tailwind CSS.
Cortex is an agentic knowledge base that transforms documents into a searchable, AI-powered knowledge graph. It ingests PDFs, Markdown, DOCX, and more, then uses LLM-driven entity and relationship extraction (GraphRAG) to build a semantic network that grows smarter with every document.
It combines three search strategies — vector similarity, keyword matching, and graph traversal — fused via Reciprocal Rank Fusion to deliver answers that go beyond simple semantic search. For complex questions, an agentic Deep Research mode decomposes queries into sub-questions, searches independently, and synthesizes comprehensive answers with visible reasoning chains.
Cortex Chat connects to any Cortex instance via its REST API and mints scoped per-group / per-user keys from a single admin-tier key you provide.
- Ask AI — single-purpose chat interface for querying your knowledge base
- Streaming responses — real-time token-by-token answer rendering via Server-Sent Events, proxied through a Next.js API route to avoid gzip buffering (toggleable in settings)
- Deep Research mode — agentic multi-step RAG for complex questions, with live thinking steps (auto-expanding during streaming), retrieval progress, and sub-question decomposition
- Inline citations —
[src_N]annotations render as clickable numbered badges linked to source documents - Source explorer — click any citation or source chip to view the full document chunk in a modal with relevance scores
- Collection scoping — defaults to all collections the user's group has access to; narrow to a single collection via the settings panel
- Conversation history — multi-turn chat with full context passed to the backend
- Server-side chat history — sessions, messages, and auto-generated titles persist per-user in SQLite, so chats follow the user across devices
- Email/password sessions —
argon2idpassword hashing, opaque session cookies with a 30-day sliding TTL, stored server-side in SQLite - Superadmin bootstrap — superadmin row is upserted from env on every boot (rotate by editing env + restart)
- User & group management — superadmin creates users at
/admin, assigns each to exactly one group, and edits per-group collection scope - Per-group read keys — every chat request uses the group's
read-scoped Cortex backend key, minted by the superadmin and stored AES-256-GCM encrypted at rest - Per-user content roles — selected users get a
manage-scoped key for document upload at/upload; admin/superadmin upload via the env admin key - Web Import — content-role users can harvest web pages into a collection (paste URLs or Discover same-site links → crawled to markdown and ingested). A feature-gated toggle on
/upload; shown only when the Cortex backend hasENABLE_WEB_CRAWL=trueand a reachableCRAWL_SERVICE_URL(a crawl4ai service). Off by default — nothing to configure in this app; it auto-detects the backend flag viaGET /api/features - Login & usage analytics — superadmin dashboard charts login activity and chat usage
- Cortex chat analytics — admins define a
<cortexchatanalytics>block in/admin/settingsthat is injected server-side into every backend request (after$userEmail/$userNamesubstitution). Invisible in the chat UI; readable by backend agent skills for use cases like routing chat summaries to external systems
- Runtime branding — accent color, logo, page title, description, and default language are edited by the superadmin at
/admin/settingsand stored in SQLite. No env vars, no rebuilds. - Cortex design system (a MOCA-derived spec) — dark-first, monochrome OKLCh + one accent, glass on chrome / opaque cards
- Multilingual — English and German, selectable per-deployment
- Responsive — comfortable on both mobile and desktop
- Node.js 18+
- A running Cortex instance
- An admin-tier API key (
moca_admin_...) from your Cortex instance — used server-side to mint per-group / per-user keys
npm installCopy the example environment file and fill in your values:
cp .env.example .env.localAll configuration is server-side, read at runtime. The browser bundle contains zero secrets and zero deploy-specific values.
| Variable | Description | Default |
|---|---|---|
CORTEX_API_URL |
URL of your Cortex backend. The browser never calls the backend directly — all traffic goes through this app's server-side proxy. | http://localhost:8000 |
BACKEND_ADMIN_API_KEY |
Admin-tier Cortex backend key. Used to mint per-group/per-user keys and list collections in the admin UI. | — |
SUPERADMIN_EMAIL |
Bootstraps the superadmin user on every server start. |
— |
SUPERADMIN_PASSWORD |
Re-hashed (argon2id) on every boot, so rotating means editing env + restart. | — |
APP_ENCRYPTION_KEY |
32 random bytes, base64-encoded (openssl rand -base64 32). Encrypts Cortex backend keys at rest in SQLite (AES-256-GCM). |
— |
DATABASE_PATH |
SQLite file path. Avatars live alongside it under <dirname>/avatars/. |
./data/cortex-chat.db |
Branding is DB-backed. Accent color, logo, page title, description, and default language are managed at runtime by the superadmin at
/admin/settingsand stored in SQLite. Changing branding never requires a rebuild or a restart.Security note: Never prefix
BACKEND_ADMIN_API_KEY,APP_ENCRYPTION_KEY, orSUPERADMIN_*withNEXT_PUBLIC_— those would be baked into the client bundle.Fail-fast validation: On boot the app validates that
BACKEND_ADMIN_API_KEY,APP_ENCRYPTION_KEY(32-byte base64),SUPERADMIN_EMAIL, andSUPERADMIN_PASSWORDare present and well-formed. Misconfigured deploys exit with a single error listing every issue.
npm run devnpm run build
npm startThe project ships with a multi-stage Dockerfile and a Docker Compose file. There are no build-time env vars — every deploy-specific value is runtime, so the same image can serve any tenant.
Two distinct paths, and they don't share storage conventions:
- Managed platforms (Coolify, Dokploy) deploy
docker-compose.ymlas-is. It mounts/app/datafrom a named volume (cortex-chat-data), which is created owned by the image's runtime user (nextjs, uid 1001), so the container can write it without any--useroverride. Don't repurpose this file for local bind-mounted dev — see the note below. - Local development is normally just
npm run dev. If you want to run the built image locally against your repo's./datadirectory (a host bind mount), you must run the container as your own UID — see Local development with the Docker image.
docker build -t cortex-chat .
docker run -p 3000:3000 \
-e CORTEX_API_URL=https://your-cortex-instance.com \
-e BACKEND_ADMIN_API_KEY=moca_admin_your-admin-key \
-e SUPERADMIN_EMAIL=admin@example.com \
-e SUPERADMIN_PASSWORD=change-me \
-e APP_ENCRYPTION_KEY="$(openssl rand -base64 32)" \
-v cortex-chat-data:/app/data \
cortex-chatAfter first boot, log in as the superadmin and customize branding (accent, logo, title, language) at /admin/settings.
To run the production image locally while keeping the SQLite DB in your repo's ./data (editable from the host, survives container recreation), bind-mount ./data and run as your own UID:
docker build -t cortex-chat .
docker run -d --name cortex-chat \
--restart unless-stopped \
--user "$(id -u):$(id -g)" \
-p 3001:3000 \
--env-file .env \
-v "$(pwd)/data:/app/data" \
cortex-chat--user "$(id -u):$(id -g)"is required for the bind mount. The image runs asnextjs(uid 1001); your./datais owned by your host user, so without this override the container can't write and boot fails withSQLITE_READONLY: attempt to write a readonly database. (The named-volume examples above and Compose don't need it — Docker creates those volumes owned by the image user.)--env-file .envloads runtime config. Use an unquoted.env(Docker's--env-filedoes not strip quotes or treat inline#as a comment, unlike dotenv).- The container listens on
3000internally;-p 3001:3000exposes it onhttp://localhost:3001. CORTEX_API_URLmust be reachable from inside the container.http://127.0.0.1:8000/localhostresolves to the container itself, not your host — using it yields502(proxy logs showECONNREFUSED). If your Cortex backend runs in Docker too, attach this container to the backend's network and address it by container name, e.g. add--network cortex-app_defaultand setCORTEX_API_URL=http://cortex-backend:8000. If the backend listens on the host, usehttp://host.docker.internal:8000with--add-host=host.docker.internal:host-gateway.
To pick up edited env values, recreate the container (docker rm -f cortex-chat then re-run) — docker restart alone keeps the old environment.
This is the deployment target for managed platforms (Coolify, Dokploy). It persists /app/data in a named volume (cortex-chat-data) owned by the image's runtime user, so no --user override is needed. For local dev against your repo's ./data, use Local development with the Docker image instead — don't change this file.
- Create a
.envfile (or copy from.env.example):
CORTEX_API_URL=https://your-cortex-instance.com
BACKEND_ADMIN_API_KEY=moca_admin_your-admin-key
SUPERADMIN_EMAIL=admin@example.com
SUPERADMIN_PASSWORD=change-me
APP_ENCRYPTION_KEY=base64-32-bytes-here
PORT=3000- Build and run:
docker compose up -d --buildBoth deploy the docker-compose.yml unchanged.
- Create a new Docker Compose resource and point it to this repository
- Set the variables in the platform's environment settings (all runtime):
| Variable | Value |
|---|---|
CORTEX_API_URL |
URL of your Cortex backend |
BACKEND_ADMIN_API_KEY |
Your admin-tier Cortex backend key |
SUPERADMIN_EMAIL |
Bootstrap email for the superadmin |
SUPERADMIN_PASSWORD |
Bootstrap password for the superadmin |
APP_ENCRYPTION_KEY |
32 random bytes, base64-encoded |
- Set the port to
3000 - Deploy — the platform builds the image and starts the container. Branding is configured in
/admin/settingsafter first login.
Any platform that supports Dockerfile-based builds will work. Set the variables above as runtime environment variables (no build args). The container exposes port 3000 and persists state under /app/data — mount a volume there.
- User types a question and hits send (or presses Enter)
- The frontend sends a POST request to the local
/api/ask/streamproxy, which forwards it to the backend withAccept-Encoding: identityto prevent gzip buffering of the SSE stream - The backend streams SSE events:
sources→graph_context→contenttokens →done - In Deep Research mode, additional events appear before content:
thinkingsteps (displayed live in an auto-expanding panel),sub_questions,retrievalprogress, andretrieval_stats - Citations in the answer (
[src_1],[src_2], etc.) are rendered as interactive badges that open the source modal
All branding (accent color, logo, page title, description, default language) lives in the app_settings SQLite table and is edited by the superadmin at /admin/settings. The server SSRs the values into the initial HTML (no flash of defaults), and /api/config returns them at runtime for client-side reactivity.
There is one privileged backend key — BACKEND_ADMIN_API_KEY — which lives in env and never leaves the server. The app uses it as a "factory" to mint narrower, per-tenant keys against the Cortex backend: a read-scoped key per group (for chat), and optionally a manage-scoped key per user (for uploads). Those minted keys are stored encrypted in this app's SQLite and injected as X-API-Key when the relevant user makes a request. So end users never see any key — they just have a session cookie, and the server picks the right minted key based on their group / role.
Roles live in users.role. Three values, each gating what's reachable in the UI and what kind of key resolves on backend calls:
| Role | Who | What they can do | Which key signs their backend calls |
|---|---|---|---|
superadmin |
Exactly one; bootstrapped from env on every boot (SUPERADMIN_EMAIL + SUPERADMIN_PASSWORD, re-hashed with argon2id each start) |
Everything an admin can do, plus manage/promote/demote admin users |
Env admin key for uploads; otherwise the minted keys below |
admin |
Created by the superadmin (or another admin) | Full /admin access: create users + groups, mint group chat keys, grant content roles, edit settings, view analytics, manage the logo / cortex chat analytics template |
Env admin key for uploads (bypasses the content-role flow); read access still goes via the group's chat key |
user |
Default for created accounts; belongs to exactly one group | Chat, browse history, edit own profile / avatar / password. If granted a "content role" by an admin, can also upload documents at /upload |
Group's read key for chat; their own minted manage key for uploads (only if content role granted) |
| Key | Permission | Stored where | Used for | Created by |
|---|---|---|---|---|
BACKEND_ADMIN_API_KEY (env) |
admin |
env only — never written to SQLite, never sent to the browser | Mints per-group / per-user keys, lists collections in the admin UI, signs admin & superadmin uploads | You, when you generate an admin-tier key in your Cortex backend |
| Group chat key | read, scoped to a set of collections |
api_keys.encrypted_value (AES-256-GCM with APP_ENCRYPTION_KEY), referenced by groups.chat_key_id |
Every /api/ask* and /api/proxy/* call by a user in that group |
The app, when an admin creates a group — calls the Cortex backend with the env admin key to mint a read key, then encrypts + stores the response |
| User content key | manage, scoped to a set of collections |
api_keys.encrypted_value, referenced by users.content_key_id |
/api/me/upload — only user-role accounts granted a content role have one |
The app, when an admin grants the user a content role — same minting flow, but manage scope |
So every key in api_keys was minted by the env admin key against one specific Cortex backend instance. Re-pointing CORTEX_API_URL at a different backend invalidates them all (see "Switching backends" below).
Users sign in with email + password (argon2id). Sessions are DB-backed (sessions table) with an opaque cookie token and a 30-day sliding TTL. Middleware checks for the cookie on protected routes; route handlers re-validate against DB via getAuth() / requireAuth() / requireAdmin() / requireSuperadmin().
api_keys.collection_ids is a JSON array; [] means "all collections" (matches the Cortex backend convention). The backend filters reads by the key's scope automatically, so the in-UI collection dropdown only ever shows collections the user's group is actually allowed to see.
Group chat keys are minted against a specific backend instance — they live in that backend's own key store. If you re-point CORTEX_API_URL at a different backend, every key in your api_keys table is unknown to the new backend, and every chat call returns 401. There is currently no in-UI "rotate key" action on an existing group. To recover:
- Recreate the group. At
/admin, create a new group (this mints a fresh chat key against the now-current backend). Reassign users to the new group via the user editor. Delete the old group. Per-user chat history survives because it's keyed by user, not group. - Or, in dev, wipe the DB.
rm data/cortex-chat.dband restart — the superadmin is re-bootstrapped from env automatically. You'll lose users, groups, and chat history, but it's the fastest reset.
Admins can define a template in /admin/settings (stored in the app_settings table, key cortexAnalyticsTemplate). On every /api/ask/stream call, the server proxy:
- Renders the template —
$userEmailand$userNameare replaced with the authenticated user's values (username falls back to email when blank). - Prepends the rendered string as the first entry of
conversation_history(role: "user") before forwarding to the Cortex backend. - Never round-trips the block to the browser or persists it in
chat_messages— re-applied per request from current settings + auth context.
Example template:
<cortexchatanalytics>
This conversation was held by $userEmail (name: $userName)
</cortexchatanalytics>
Backend agent skills (loaded from SKILL.md at the start of every chat session) can read the block and, for example, post chat summaries to a CRM or BI tool with the user identity attached. Leave the template empty to disable injection entirely.
src/
├── app/
│ ├── admin/ # superadmin dashboard (users, groups, analytics, settings)
│ ├── api/
│ │ ├── admin/ # superadmin-only routes: users, groups, content-roles, library, keys, logo, settings, login-events, analytics
│ │ ├── ask/stream/ # SSE chat proxy (bypasses gzip buffering, injects cortex chat analytics)
│ │ ├── auth/ # login, logout, session/me
│ │ ├── avatars/ # serves user avatar files
│ │ ├── branding/ # serves uploaded logo
│ │ ├── config/ # runtime config (accent color, logo URL, locale, upload limits)
│ │ ├── me/ # self-service: profile, password, avatar, chats, upload, upload-scope
│ │ └── proxy/[...path]/ # generic backend proxy for read-scope calls (e.g. collections)
│ ├── login/ # login page
│ ├── profile/ # profile (username, avatar, password)
│ ├── upload/ # content-role upload UI
│ ├── globals.css # MOCA design tokens, dark theme, markdown + citation styles
│ ├── layout.tsx # Root layout (dark class, branding bootstrap)
│ └── page.tsx # Main chat page (state, API orchestration)
├── components/
│ ├── admin/ # AdminShell, Modal, shared admin UI primitives
│ ├── ChatInput.tsx # Text input, mode toggle, settings cog
│ ├── MessageList.tsx # Scrollable message area with empty state
│ ├── MessageBubble.tsx # User/assistant messages, thinking steps, inline citations
│ ├── SettingsPanel.tsx # Streaming toggle + collection scope selector
│ ├── Sidebar.tsx # Slide-in chat history (server-side, per-user)
│ ├── SourceModal.tsx # Full source content viewer
│ ├── Header.tsx # Logo + sidebar toggle
│ └── ConfigBootstrap.tsx # Loads /api/config on mount, applies CSS vars + locale
├── lib/
│ ├── auth/
│ │ ├── session.ts # getAuth / requireAuth / requireSuperadmin, cookie session lookup
│ │ ├── password.ts # argon2id hash / verify
│ │ ├── crypto.ts # AES-256-GCM encryptSecret / decryptSecret (APP_ENCRYPTION_KEY)
│ │ ├── backend-key.ts # getGroupChatKey / getUserContentKey — resolves the X-API-Key for backend calls
│ │ ├── superadmin-bootstrap.ts # upserts superadmin row from env on every boot
│ │ └── cookie.ts
│ ├── backend/ # Cortex backend admin client (mint keys, list collections via BACKEND_ADMIN_API_KEY)
│ ├── db/
│ │ ├── schema.ts # Drizzle schema (users, groups, api_keys, sessions, chat_sessions, chat_messages, login_events, usage_events, app_settings)
│ │ ├── client.ts # better-sqlite3 + Drizzle setup
│ │ ├── migrate.ts # applied on server start via instrumentation
│ │ └── migrations/ # Drizzle-generated SQL
│ ├── api.ts # client-side API helpers (ask, stream parsing, title generation)
│ ├── cortex-analytics.ts # template render + conversation_history injection (server-only)
│ ├── settings.ts # app_settings accessors + CORTEX_ANALYTICS_VARIABLES registry
│ ├── config.ts # runtime config payload builder
│ ├── branding.ts # logo file + uploaded asset paths
│ ├── chatHistory.ts # client-side chat session helpers
│ └── i18n.ts # en/de translations
├── middleware.ts # cookie-based gate on protected routes (PUBLIC_PATHS allowlist for login, config, auth callbacks)
└── instrumentation.ts # boot hook: runs migrations + superadmin bootstrap
Runtime state lives under ./data/:
data/cortex-chat.db— SQLite database (users, groups, api_keys, sessions, login_events, chat_sessions, chat_messages, usage_events, app_settings)data/avatars/<userId>.webp— user profile imagesdata/branding/— uploaded logo
The data/ directory is gitignored and meant to be bind-mounted as a volume in Docker (see docker-compose.yml). Schema changes are generated with npm run db:generate and applied automatically on server start (or manually via npm run db:migrate).
- Next.js 16 (App Router, Turbopack, standalone output)
- React 19, TypeScript 5
- Tailwind CSS 4 with the Cortex design system (dark-first, OKLCh tokens)
- SQLite + better-sqlite3 + Drizzle ORM for users, sessions, groups, keys, chat history, analytics
- @node-rs/argon2 for password hashing, AES-256-GCM (Node
crypto) for at-rest key encryption - zod for route input validation
- Recharts for the admin analytics dashboard
- react-markdown + remark-gfm for rendering markdown responses
- Docker multi-stage build with standalone output