A lean chat interface that talks straight to the model APIs. No subscription limits, no five-hour windows, no weekly caps — only the per-minute rate limits and the monthly spend cap of whichever provider you point it at.
Included: several providers side by side, connectors to external APIs, streaming, user accounts with roles, projects, full-text search, web search, sharing, a prompt library, export, a cost chart, your own name, colours and logo, an interface in English, German, Ukrainian and Arabic (with RTL), file upload (images, PDF, text), extended thinking, prompt caching.
cp .env.example .env
nano .env # set SESSION_SECRET and ANTHROPIC_API_KEY
docker compose up -dThen available at http://localhost:3000. The first visit creates the admin account.
npm install
cp .env.example .env
nano .env
npm startFor PM2, run pm2 startOrReload ecosystem.config.cjs --update-env; the application loads
the same .env file automatically.
Everything runs through environment variables (.env):
| Variable | Required | Meaning |
|---|---|---|
SESSION_SECRET |
yes | Random string. Encrypts personal API keys and signs sessions |
ANTHROPIC_API_KEY |
no | Only used once, to seed the first provider on the very first start. Afterwards keys live in the admin area |
COOKIE_SECURE |
no | Set to true once HTTPS is in place |
PUBLIC_ORIGIN |
no | Public HTTPS origin used for MCP OAuth callbacks, e.g. https://chat.example.com |
TRUST_PROXY |
no | Number of trusted reverse-proxy hops; normally 1 for nginx or Caddy |
PORT |
no | Defaults to 3000 |
DATA_DIR |
no | Where the database and uploads live, defaults to ./data |
CONNECTORS_BLOCK_PRIVATE |
no | true blocks the private network for administrators as well |
Generate a solid SESSION_SECRET with:
openssl rand -hex 32Set it once and never change it. The key that encrypts personal API keys is derived from it — after a change everybody has to enter their key again and all sessions expire.
On first visit the interface walks through creating the first account, which becomes the administrator. Coming from an older version without accounts, just create that admin on first start: existing conversations, projects and attachments are assigned to it automatically.
The application is not tied to one vendor. Under Administration → Providers you add as many as you like, each with its own key, its own models and its own prices.
Two adapter families cover the field:
- Anthropic — the native Messages API. The only one with explicit cache breakpoints, extended thinking and the server-side web search tool.
- OpenAI compatible — everything speaking the OpenAI chat-completions API: OpenAI itself, OpenRouter, Groq, Together, Mistral, DeepSeek, xAI, and local servers such as Ollama or vLLM. Point the base URL at the endpoint and you are done.
On the very first start an Anthropic provider is seeded from ANTHROPIC_API_KEY with the
three Claude models, so nothing changes for an existing installation.
Every model carries capability flags — thinking, web search, caching, images, PDF. The interface follows them: switches the chosen model cannot honour go quiet instead of producing an error, cache breakpoints are only sent where they mean something, and PDFs are not pushed at endpoints that would reject them. That is the honest way to be provider-agnostic: not every model can do everything, and pretending otherwise breaks at the worst moment.
Adding a provider takes a name, the kind, the base URL and a key. Test connection asks the endpoint for its model list, which proves the key works and lists everything not added yet as a button — one click adds the model. Prices and capabilities you fill in afterwards in the row, because no provider publishes a pricing endpoint and the capability flags are a judgement call. A model with prices left at zero simply reports zero cost.
When new models appear, press Test connection again: anything new shows up as a fresh button. Nothing polls in the background. If a provider retires a model id, its calls start failing with the provider's own error — switch that model off and add the successor; existing conversations keep their history either way.
There is no cap on providers, models, or personal keys — they are plain database rows. The only structural limit is one personal key per person and provider. Past a few dozen active models the picker simply gets long.
Adding another adapter family is one file in providers/ plus one line in
providers/index.js — nothing else in the application changes.
A connector gives the model tools it can call mid-conversation — a price feed, an internal service, a documentation index. Two types exist:
MCP servers (the default). Paste the server's Streamable HTTP endpoint (usually ending
in /mcp), add a token if the service wants one, press Connect. The app runs the full MCP
handshake — initialize and tools/list — before saving anything, so a wrong path,
bad token or non-MCP address is refused with a specific message instead of leaving a broken
connector behind. On success the discovered tools appear immediately, named
service__tool, and the connector is switched on for the current conversation. The
protocol version is negotiated at initialize and reused, sessions (Mcp-Session-Id) are
carried across calls, and tool lists refresh automatically about once a minute.
Tools the server explicitly annotates readOnlyHint: true run without asking; every other
tool shows the exact call and waits for a click on Run. The annotation is a claim by an
untrusted server, so the safe direction is confirm-unless-proven-safe.
OAuth in the browser. Servers that answer 401 get a Sign in in the browser button.
The app implements the MCP authorization flow: protected-resource metadata (RFC 9728) and
authorization-server metadata (RFC 8414) discovery, dynamic client registration (RFC 7591),
then an authorization-code + PKCE sign-in in a popup, with the resource parameter
(RFC 8707) bound to the MCP URL. The code is exchanged server-side; tokens are stored
encrypted on the connector and refreshed silently — an expired token mid-conversation
heals itself, and only a revoked grant surfaces, as a Sign in again button on the
connector card. The state is single-use, time-boxed, and bound to the signed-in user.
Supported: remote Streamable HTTP servers with no auth, bearer token, custom header, query-parameter key, or browser OAuth as above. Not supported: the legacy HTTP+SSE transport, and local stdio servers — the latter deliberately: a multi-user web application should not spawn arbitrary processes on its host.
HTTP APIs. For services without MCP, define the service once (base URL, authentication)
and then one endpoint per call under Advanced. Parameters are written compactly as
name:where:type, with ! marking a required one — for example
symbol:query:string!, limit:query:number. where is query, path or body.
Outbound requests are pinned. The SSRF guard resolves the hostname, checks every address, and then opens the socket to the address it checked — the request cannot be redirected to a different host by a second DNS answer (DNS rebinding), and 3xx redirects are never followed.
Credentials never leave the server. The model sees the result of a call, never the key. The browser sees a masked hint at most.
Anything that changes data asks first. Every non-GET endpoint is marked as needing confirmation: the answer pauses, the exact call including its parameters appears in the chat, and it only runs after a click. Declining is passed back to the model as a refusal, so it does not simply try again.
Who may set one up. Administrators create connectors for the whole team and may point them at internal addresses — they configure the server anyway. Everyone may create their own, but only against public addresses. That distinction is enforced, not advisory:
localhost,10.x,172.16–31.x,192.168.x,127.x, carrier-grade NAT, IPv6 unique-local and link-local are refused for ordinary accounts- the cloud metadata service (
169.254.169.254,metadata.google.internaland friends) is refused for everyone, administrators included — it hands out instance credentials to anything that can make an HTTP request - host names are resolved and every returned address is checked, not just the first
- the address is re-checked after parameters are filled in, so a path parameter cannot smuggle a different host
- redirects are not followed, so a 302 cannot step around any of the above
- calls time out after 10 seconds and results are capped, so nothing can flood the context
Set CONNECTORS_BLOCK_PRIVATE=true to close the private range for administrators too.
Switching them on. Three ways, none of them automatic by accident:
- the Connectors button in the toolbar, for this conversation
- on by default per connector, so every new conversation starts with it
/toolsin the message box:/toolslists them,/tools bybitswitches one on,/tools alleverything,/tools offeverything off. Commands never reach the model.
A project can also carry connectors, so every conversation inside it starts with them.
Why not simply everything, always: each active tool sends its schema with every request of that conversation. Ten tools are roughly one to two thousand tokens per message — after the first call the cache brings that down to about a tenth, but not to zero. More importantly, the more tools stand open, the more likely the model reaches for one you did not mean. The model may chain at most six tool rounds per answer.
Everyone gets their own account with an email address and password. Accounts are created by an administrator under Administration → Users.
What is separated: a conversation is visible only to the person it belongs to — even administrators cannot see other people's chats, not in the list, not through search, not via a direct URL, not through export. That is deliberate: cost control does not justify reading content. Uploaded files cannot be pulled in through someone else's attachment ID either; an attachment belongs to whoever uploaded it.
What can be shared: projects. When creating or editing one, it can be opened up to everyone — then all colleagues see the system prompt and the pinned files and can work in it, while their conversations stay private. Changing a shared project is restricted to its owner plus administrators: settings, files and deletion are off limits for readers. Individual conversations, answers and files go through the sharing feature below.
Where the administrator role really does more: creating, disabling and deleting accounts, setting roles and limits, resetting passwords; changing design and logo; seeing the usage breakdown across everyone; cleaning up storage. It also sees all projects including their system prompts and pinned files — necessary to administer and tidy them. Out of reach are other people's conversations and their personal API keys.
Who pays. Both models work, and they mix:
- Nothing stored → everything runs on the provider's key, so on your account. Under Administration → Users you see the cost per person and can set a monthly limit in USD. Once reached, the next request is refused before any cost is incurred.
- A personal key under "My account" → that person pays themselves and your bill is untouched. Keys are kept per provider, so somebody can bring their own OpenAI key while still using the company's Anthropic key. Each one is stored encrypted with AES-256-GCM and never handed back out; the interface shows a masked hint only. The monthly limit deliberately does not apply when a personal key pays — it protects your bill, and your bill is not involved.
The limit is checked before each request but can only brake up to the line, not to the cent: the answer in flight is finished, and two requests started at the same time can both slip through. For a hard ceiling, set a spend limit in the Console.
Worth knowing: a Claude subscription (Pro or Max) cannot be plugged in here. The API offers no OAuth path for a third-party application to act on behalf of a claude.ai account — there are only API keys and Workload Identity Federation. So team members cannot "sign in with their Claude account"; they need either an account here or their own API key from the Console.
Alongside that, Workspaces in the Console are worth a look: up to 100 of them, each with its own key, its own spend limit and its own rate limits, plus a cost breakdown through the Usage and Cost API. One workspace per team or client pairs well with the monthly limits here.
Conversations, single answers and individual files can be handed out — either to selected colleagues or through a link.
Every share is a snapshot. The content is frozen when the share is created. Later messages never show up at the recipient, and deleting the original does not break an existing link. That is the point: you decide what leaves your hands at that moment, not your future self.
- To selected people — they see it read-only under "Shared with me". Nothing leaves the sign-in.
- Through a link — a long random token that works without an account. Whoever passes the link on passes on the content, so treat it accordingly.
Both can carry an expiry date (1, 7 or 30 days) and can be revoked at any time. Expired shares are removed automatically. A shared file survives deletion of its conversation as long as the share exists.
Share buttons sit in the header (whole conversation), under each answer (that one answer) and next to project files (that one file).
The switch in the header hands the model the server-side search tool for this conversation. It decides on its own whether and how often to search (at most five times per request), and the pages it used appear as a source list under the answer. Anthropic models only — the switch goes quiet on everything else.
This costs extra: roughly one cent per search, on top of the tokens for the results. The number of searches appears in the line below each answer and is included in the cost. Project settings can turn search on permanently — sensible for research projects, unnecessary for everything else.
The tool version lives in config.js and can be bumped there once Anthropic ships a newer
one.
Prompt library. The button next to the message box stores recurring instructions and inserts them with one click. Your own prompts can be opened up to the team.
Export. Any conversation downloads as a Markdown file (including sources and attachment names) or goes to PDF through the print dialog — a print stylesheet hides the sidebar, header and message box.
Cost history. Administration → Usage holds a stacked bar chart over 7, 30 or 90 days. Users see their own cost by model, administrators additionally everyone side by side. Underneath sits the same information as a table — not for completeness: with light colour schemes some series tones fall below the contrast threshold, and with money you want to read exact numbers anyway.
The series colours come from a fixed palette validated against colour blindness and are deliberately independent of the configured theme — otherwise adjacent series would stop being distinguishable under some colour choices. Depending on the lightness of the background, the chart switches between the light and dark stepping. Past the sixth series the rest folds into "Other" rather than inventing more colours.
The database is plain text and stays small. Space goes to attachments.
Cleanup is automatic: deleting a conversation takes its attachments with it — unless the same file is still pinned to a project or held by a share. At startup and once a day after that, the server also looks for leftovers: records without references and files without records. Administration → Usage shows the space in use and offers a manual sweep.
The interface and its error messages come in English, German, Ukrainian and Arabic. The picker sits at the top left of the sidebar and is remembered in the browser. English is the default; the browser language is used when it is one of the four.
With Arabic the whole interface flips to RTL. Mixed content stays readable: code blocks, amounts and Latin titles keep their own reading direction, and every message follows its own content. A German chat therefore still looks right inside the Arabic interface.
The server knows no language at all — it returns error codes and the interface translates
them. Adding another language is a block in public/i18n.js plus an entry in LANGS, with
no backend change.
The API key lives exclusively in the backend and is never served to the browser. That is the whole reason for the proxy: a key in the frontend would be scraped within hours and billed to you.
Personal keys of team members sit in the database encrypted with AES-256-GCM and never leave the server in the clear.
Three things before the server is reachable from the internet:
- Create the first account immediately. While none exists, anyone who knows the URL can make themselves the administrator. So open it once right after the first start.
- Put TLS in front. The
docker-compose.ymldeliberately binds to127.0.0.1only. A reverse proxy with a certificate belongs in front (Caddy needs two lines, nginx a bit more). Then setCOOKIE_SECURE=true. - Set a spend limit in the Console. Under Settings → Limits you can put a monthly cap below your tier's ceiling. That is the brake if someone does get access.
There is a brake against password guessing: after eight failed attempts the combination of IP address and email is locked for a growing period, while other accounts stay unaffected. The counter lives in memory and starts over after a restart. For anything harder, put fail2ban or a rate limit in the reverse proxy in front.
Example Caddyfile:
chat.your-domain.com {
reverse_proxy 127.0.0.1:3000
}
A project bundles conversations that belong together and gives them a shared frame:
- Its own system prompt. It takes precedence over the one in settings and applies to every conversation in the project.
- Its own default model. Preselected when you switch into the project.
- Pinned files. They ride along in every conversation of the project.
The project files are placed in front of the first message and get their own cache breakpoint. Together with the system prompt this forms a stable prefix that is read from cache from the second message on — at roughly a tenth of the price. That is what makes projects pay off: a 20,000-token manual costs full price once and almost nothing after.
In total the interface sets three of the four allowed cache breakpoints: system prompt, project files, prior transcript.
Deleting a project keeps its conversations; they move back into the general list.
The search box in the sidebar covers all messages through SQLite FTS5, not just titles. Clicking a hit opens the conversation and jumps to the highlighted spot.
- Starts at two characters and searches while you type (the last term is treated as a
prefix —
backofindsBackoff) - Diacritics are ignored, so German umlauts and Arabic harakat do not get in the way
- With a project open it searches inside that project only
- Title hits appear as well, without duplicates
On first start against an existing database, all present messages are indexed automatically.
Prices are kept per model under Administration → Providers, in USD per million tokens. The seeded Claude models start at list price:
| Model | Input | Output |
|---|---|---|
| Opus 5 | $5 / M tokens | $25 / M tokens |
| Sonnet 5 | $3 / M tokens | $15 / M tokens |
| Haiku 4.5 | $1 / M tokens | $5 / M tokens |
Sonnet 5 has an introductory price of $2 / $10 until 31 August 2026, so the display errs slightly on the conservative side until you adjust it.
Cost is computed from whatever the provider reports back. Endpoints that report cached tokens separately get the cache discount applied; endpoints that report nothing land at zero rather than at a guess.
Prompt caching is built in and makes the biggest difference in long conversations: the system prompt and the prior transcript are marked as a cache prefix. Cached tokens read cost only 10 % of the normal input price, writing to the cache 125 %. From the second message of a conversation this practically always pays off. The cache holds for five minutes by default and extends on every hit.
The sidebar shows this month's spend and the total; every answer carries its own token and cost figures.
No session or weekly limits. Instead:
- Per minute: requests (RPM), input tokens (ITPM), output tokens (OTPM). The values depend on your organisation's usage tier and rise automatically with history. Cached input tokens do not count against ITPM for most models.
- Per month: your tier's spend cap. Once reached, usage pauses until the month rolls over.
On exceeding a limit the API answers HTTP 429 with a retry-after header. The server
turns that into a readable message in the chat instead of a crash.
server.js Express server: accounts, providers, projects, search, sharing, streaming proxy
providers/ One adapter per API family → add new providers here
connectors.js Address guard, limits, and execution of connector calls
auth.js Password hashing, sessions, encryption of API keys
config.js Seed models, web search tool, upload limits
branding.js Presets and default colours → add new themes here
db.js SQLite schema, migrations, full-text index
public/index.html The complete frontend, no build step
public/i18n.js Translations → add new languages here
seed-demo.js Sample data for trying things out (optional)
data/ Database, uploads and logo (not in git)
Existing databases are migrated automatically on start: missing columns are added and
existing messages are pulled into the search index. There is no downgrade path — run
cp -r data data.bak before updating.
The database is a single SQLite file at data/chat.db. A backup is a cp of the data
folder.
- Enter sends, Shift+Enter adds a line break
- Stop aborts a running answer — text already written is kept
- Settings opens the system prompt, output limit and thinking budget
- Extended thinking shows the reasoning collapsed above the answer (Opus 5 and Sonnet 5)
- Files can be dragged into the window or pasted with Ctrl+V into the message box
- If an answer runs into the token limit, a notice with Continue appears — the new text is appended to the same answer, no second message is created
- On narrow screens ☰ opens the sidebar; it closes itself after a pick
- The system prompt applies per conversation and is cached — except inside a project, where the project sets it
- + next to "Projects" creates one, a click on the name switches in, × in the header switches back out
The Analysis switch in the toolbar gives Anthropic models their server-side code execution tool: Python/Bash in an isolated container run by Anthropic, the same mechanism behind data analysis in the Claude apps. Files the sandbox creates are fetched through the Files API, stored as ordinary attachments, and rendered in the answer — charts inline, other files as download cards, access restricted to the requesting user. Pricing is per container-hour with a large monthly free allowance (see Anthropic's docs); token costs are tracked as usual. The capability is per model (Administration → Providers); existing databases are backfilled automatically at start.
npm testRuns the automated suite in test/: the real server is started as a child process against
in-process mocks (a Claude-shaped provider API and an MCP server), covering connector
creation with test-before-save, authentication failures, discovery, session and
protocol-version handling, read-only versus confirmed tool calls through a live chat
stream, the decline path, and the address guard. No network beyond 127.0.0.1, no real
API key.
The suite spans sixteen files: MCP discovery/auth/session handling, OAuth including PKCE and refresh, origin/CSRF policy, transport fallbacks, adaptive thinking, document export, sandbox files, editing/regeneration, auto-compaction, memory, research mode, prompt caching with the live activity view and content-sniffed logo upload, conversation pinning/renaming, cross-provider image generation with its auto/confirm/off borrow modes, dictation via the transcription proxy, and fal.ai image/video generation through the queue API.
Obvious next steps, if wanted:
- A coding agent through the Agent SDK — the programmable form of Claude Code. It belongs in an isolated container per project, since it executes code; that is a separate piece of work with its own security design, not an afternoon's addition
- More adapter families, e.g. Google Gemini or AWS Bedrock with their native APIs
- The legacy HTTP+SSE MCP transport, for old servers that offer nothing else
- Sign-in through SSO (OIDC), so nobody has to manage another password
- Batch API for asynchronous bulk work at half the price
- Semantic search alongside the full-text one, through embeddings