Welcome + starter prompts![]() |
Streaming chat response![]() |
Conversation history![]() |
Settings panel![]() |
Kiosk mode — gear visible![]() |
Full kiosk — gear also hidden![]() |
The same app in two kiosk configs — rebranded as "ACME Inc.", custom theme, history sidebar / web search / MCP picker hidden. Right variant adds CHATFRAME_SHOW_SETTINGS=0 to drop the gear too. Note the live .NET 10 answer in both — web search runs transparently server-side even with no globe toggle.
Embeddable AI chatbot framework. Drop-in branding, kiosk-friendly, MCP-ready.
OSS chat starters split into two camps. Full-fat platforms (Open WebUI, LibreChat) are feature-rich but built around "I run my own AI workbench" — hard to embed in a customer-facing site, hard to lock the UI down for a support widget, hard to rebrand without forking and maintaining a soft fork forever. Minimal starters (the Vercel chatbot template) are easy to clone, but every polish detail — multi-provider, MCP, attachments, streaming-that-survives-mobile, brand-it-yourself config, kiosk lockdown — is on you to build.
The middle ground — "a polished chatbot I can drop into my site, white-label, and self-host in a container" — doesn't really exist in OSS. It's either build-it-yourself or pay for SaaS (Intercom, Crisp, Tidio) that owns your branding and your conversations.
Chatframe: one Docker image + one mounted config volume. Set env vars for an LLM provider and a JWT secret, edit one JSON file to rebrand, and you have a branded chatbot running on your domain. To lock it down for an embed or public kiosk deployment, flip a handful of CHATFRAME_SHOW_* env flags — the matching UI controls disappear and the features keep running transparently server-side.
A single Next.js app, no database, no signup. Point it at any of 12 LLM providers, mount a config volume of icons/themes/MCP servers, and ship.
- Multi-provider via Vercel AI SDK — 8 cloud (Anthropic, OpenAI, Gemini, Groq, Mistral, Cohere, Perplexity, AWS Bedrock) + 3 local OpenAI-compatible (Ollama, llama.cpp, LM Studio). Switch with one env var. Prompt caching enabled on Anthropic out of the box (~10% input-token cost on multi-turn cache hits).
- MCP support — add any number of MCP servers (HTTP or stdio) via
config/chatframe-mcp.json. Tools are namespaced and auto-discovered. - Web search — native or Tavily —
CHATFRAME_SEARCH_BACKEND=auto(default) uses the provider's first-party search where available (Anthropic web_search, Google grounding, Perplexity Sonar) and falls back to Tavily for the rest. Set tonativeortavilyto force one. - Resumable streams — server keeps a 5-minute replay buffer; clients reconnect via
Last-Event-IDafter dropped sockets (mobile-tab background, proxy hiccup, network blip). No lost tokens. - Kiosk mode — nine env flags to lock down the UI surface: header (whole bar / just title / just icon), settings panel, chat history, web search, MCP picker, model picker, attachments. Hidden controls still run server-side using whatever's configured.
- Drop-in branding — edit
config/chatframe.config.json(app name, welcome message, starter prompts, theme colors, favicon, PWA icons). Drop aconfig/custom.cssfor fine-grained styling (fonts, per-area colors). Next page request picks up the change. No rebuild. - 25 built-in themes + custom themes + per-area CSS hooks — 13 dark + 12 light shipped; add your own under
themes[]in the config. The header, assistant bubble, and composer pill each carry a dedicated CSS class so deployments can restyle one without dragging the others. - Document + image attachments — PDF, DOCX, XLSX, plain text, images. Extracted server-side.
- Voice in / out — mic button captures speech and auto-sends; speaker toggle reads assistant replies aloud. Uses the browser's Web Speech API (no extra API key, no server cost). Recognizer + synthesizer pick the active UI locale, so language selection in Settings reconfigures voice too. Hide either via
CHATFRAME_SHOW_VOICE_INPUT=0/CHATFRAME_SHOW_VOICE_OUTPUT=0for kiosks where voice isn't appropriate. - i18n (4 locales built-in, infinite via drop-file) — English, Spanish, French, German ship in the image. Set the default with
CHATFRAME_LOCALE, let users pick in Settings, or drop aconfig/locales/<code>.jsonto add any other language without a rebuild. See Internationalisation. - Embeddable — drop an
<iframe>into any page; noX-Frame-Optionsby default. Kiosk flags + JWT-scoped per-iframe storage make it work cleanly as a support widget or in-app assistant. See Embedding (iframe). - One-click transcript export — Download icon in the header saves the current chat as Markdown.
- PWA-ready — manifest, installable on Android Chrome and desktop browsers.
- No database — conversations persist in browser
localStorage(unless kiosk mode disables persistence).
cd docker/
cp .env.example .env
# Edit .env — at minimum set JWT_SECRET and one provider API key
docker compose up --build
# → http://localhost:3008The container reads its branding, themes, MCP server list, and icons from a host-mounted volume (default: ../nodejs/config). Edit any file in that dir and the next page load reflects the change.
For a Caddy-fronted TLS deployment: docker compose -f docker-compose.prod.yml up -d --build (edit Caddyfile first to set your domain).
For a deployment behind an existing host-level reverse proxy: docker compose -f docker-compose.internal-caddy.yml up -d --build (compose joins the external proxy_net network; the app exposes no host port).
cd nodejs/
npm install
cp .env.example .env.local
# Edit .env.local — at minimum set JWT_SECRET and one provider API key
npm run dev
# → http://localhost:3000All operator config lives in two places:
- Secrets + flags → env vars (Docker
.envor bare-metal.env.local) - Branding + themes + MCP servers + icons →
nodejs/config/(the persistent volume mount in Docker setups)
Set the env var for the provider you want, plus CHATFRAME_PROVIDER to select it.
| Provider | Env var(s) | Category |
|---|---|---|
| Anthropic | ANTHROPIC_API_KEY |
cloud |
| OpenAI | OPENAI_API_KEY |
cloud |
| Google Gemini | GEMINI_API_KEY |
cloud |
| Groq | GROQ_API_KEY |
cloud |
| Mistral | MISTRAL_API_KEY |
cloud |
| Cohere | COHERE_API_KEY |
cloud |
| Perplexity | PERPLEXITY_API_KEY |
cloud |
| AWS Bedrock | AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION |
cloud |
| Ollama | OLLAMA_BASE_URL (default http://localhost:11434/v1) |
local |
| llama.cpp | LLAMACPP_BASE_URL (default http://localhost:8080/v1) |
local |
| LM Studio | LMSTUDIO_BASE_URL (default http://localhost:1234/v1) |
local |
| Web search (Tavily) | TAVILY_API_KEY (optional — see CHATFRAME_SEARCH_BACKEND below) |
— |
In Docker, point local-provider base URLs at host.docker.internal:<port> (the compose files set extra_hosts: ["host.docker.internal:host-gateway"]).
| Var | Purpose | Default |
|---|---|---|
JWT_SECRET |
Signs the per-device auth token. Anything ≥32 random chars. | (dev fallback — must be set in production) |
CHATFRAME_PROVIDER |
Selected provider id (see table above) | anthropic |
CHATFRAME_MODEL |
Provider-specific model id | claude-sonnet-4-6 |
CHATFRAME_SYSTEM_PROMPT |
Server-default system prompt | "You are a helpful AI assistant." |
CHATFRAME_TEMPERATURE |
Sampling temperature | 1.0 |
CHATFRAME_SEARCH_BACKEND |
Web search source: auto (native when available, Tavily otherwise), native (Anthropic / Google / Perplexity only), or tavily (uniform) |
auto |
CHATFRAME_LOCALE |
Default UI language for new visitors (en, es, fr, de, or any operator-supplied code). User's Settings choice overrides per device via cookie. |
en |
CHATFRAME_CONFIG_DIR |
Where chatframe.config.json + chatframe-mcp.json + icons/ live |
./config |
CHATFRAME_SHOW_HEADER |
Show the top header bar at all (1/0) |
1 |
CHATFRAME_SHOW_HEADER_ICON |
Show the app icon in the header | 1 |
CHATFRAME_SHOW_HEADER_TITLE |
Show the app name in the header | 1 |
CHATFRAME_SHOW_SETTINGS |
Show the settings gear (1/0) |
1 |
CHATFRAME_PERSIST_CHAT |
Persist chat history to localStorage + show sidebar | 1 |
CHATFRAME_SHOW_WEB_SEARCH |
Show the web search globe toggle | 1 |
CHATFRAME_SHOW_MCP |
Show the MCP server picker | 1 |
CHATFRAME_SHOW_MODEL_PICKER |
Show the provider/model pill | 1 |
CHATFRAME_SHOW_ATTACHMENTS |
Show the paperclip | 1 |
CHATFRAME_SHOW_VOICE_INPUT |
Show the mic button (Web Speech API STT) | 1 |
CHATFRAME_SHOW_VOICE_OUTPUT |
Show the speaker toggle (Web Speech API TTS) | 1 |
CHATFRAME_SHOW_SYSTEM_PROMPT_EDIT |
Show the system-prompt textarea in Settings (jailbreak vector — set 0 for whitelabel) |
1 |
CHATFRAME_SHOW_TEMPERATURE_EDIT |
Show the temperature slider in Settings | 1 |
CHATFRAME_SHOW_IMPORT_EXPORT_RESET |
Show the Data row (Import / Export / Reset) in Settings | 1 |
CHATFRAME_SHOW_DOWNLOAD_CHAT |
Show "Download chat" in the kebab menu (data-exfil control) | 1 |
CHATFRAME_SHOW_CLEAR_ALL_CONVERSATIONS |
Show the sidebar's clear-all-conversations button | 1 |
CHATFRAME_SHOW_MESSAGE_ACTIONS |
Show per-message copy / edit / regenerate icons | 1 |
CHATFRAME_SHOW_SOURCES |
Show web-search source citations under assistant replies | 1 |
Generate a JWT_SECRET with openssl rand -hex 32.
Every field below is optional — anything you omit falls back to Chatframe's defaults. The example shows a complete custom-themed deployment.
{
"name": "My Bot",
"shortName": "MyBot",
"tagline": "What it does in one line",
"defaultSystemPrompt": "You are MyBot, an assistant for ACME Corp customers. Be concise and friendly.",
"welcomeMessage": "Hi — I'm MyBot. Ask me about our products, support, or anything else. Markdown is supported in this bubble.",
"starterPrompts": [
"How do I reset my password?",
"Where's my order?",
"Talk to a human"
],
"checkForUpdatesUrl": "https://github.com/you/your-fork/releases",
"icon192": "/branding/icon-192.png",
"icon512": "/branding/icon-512.png",
"defaultTheme": "my-brand",
"hideBuiltInThemes": false,
"themes": [
{
"id": "my-brand",
"name": "My Brand",
"category": "light",
"swatches": ["#ffffff", "#ff5500", "#1a1a1a"],
"colors": {
"bg": "#ffffff",
"surface": "#f5f5f5",
"surface-2": "#ebebeb",
"surface-3": "#dbdbdb",
"primary": "#ff5500",
"on-primary": "#ffffff",
"fg": "#1a1a1a",
"fg-2": "#4a4a4a",
"fg-3": "#7a7a7a",
"fg-4": "#a5a5a5",
"scrollbar": "rgba(255,85,0,0.30)",
"scrollbar-h": "rgba(255,85,0,0.55)",
"error-bg": "rgba(220,53,69,0.10)",
"error-border": "rgba(220,53,69,0.40)",
"error-fg": "#b91c1c"
}
}
]
}What each theme color drives:
bg→ page background (around the chat column)surface→ chat bubbles, header, sidebar, settings panel, composer containersurface-2→ form inputs, search bar, hover states, table headerssurface-3→ deeper hover states inside dropdownsprimary→ send button, links, scrollbar thumb, active-state highlightson-primary→ text on top ofprimary(e.g. send-icon color)fg→ main body text on surfacesfg-2→ secondary text (subtitles, labels)fg-3→ muted text (timestamps, hints)fg-4→ most muted (placeholders, empty-state text)scrollbar/scrollbar-h→ scrollbar thumb (idle / hover)error-bg/error-border/error-fg→ error banner styling
Drop PNGs into config/icons/ and reference them as /branding/<filename> — served by a runtime route, no rebuild needed. category must be "dark" or "light" (drives the picker's grouping). swatches is the 3-color preview shown in the Settings theme picker.
Two transport types: http (Streamable HTTP MCP servers) and stdio (local processes launched on demand). Add as many entries as you want — each gets its own connection at boot and tools are namespaced <serverId>__<toolName> on the wire to avoid collisions.
{
"servers": {
"mslearn": {
"type": "http",
"url": "https://learn.microsoft.com/api/mcp",
"label": "Microsoft Learn"
},
"github-public": {
"type": "http",
"url": "https://api.githubcopilot.com/mcp",
"label": "GitHub (public read)"
},
"filesystem": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"],
"env": {
"DEBUG": "0"
},
"label": "Local filesystem"
},
"postgres": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://user:pass@db:5432/mydb"],
"label": "Postgres (read-only)"
}
}
}Field reference per transport:
http—type(required),url(required),label(optional, shown in the MCP picker; defaults to the server id)stdio—type(required),command(required, e.g.npx,python,/usr/local/bin/my-mcp),args(optional string array),env(optional string map; merged into the spawned process's environment),label(optional)
MCP servers connect at app boot. Restart the container after editing this file.
The ten CHATFRAME_SHOW_* flags + CHATFRAME_PERSIST_CHAT let you sculpt the UI surface per deployment. Hidden = the UI control is gone; the backing feature still runs server-side using whatever's configured. To disable a feature entirely, don't configure it (e.g. omit TAVILY_API_KEY and skip native search to disable web search even when the toggle is hidden).
Typical embedded-widget config (no header, no chat history, no toggles — just an input):
CHATFRAME_SHOW_HEADER=0
CHATFRAME_SHOW_SETTINGS=0
CHATFRAME_PERSIST_CHAT=0
CHATFRAME_SHOW_WEB_SEARCH=0
CHATFRAME_SHOW_MCP=0
CHATFRAME_SHOW_MODEL_PICKER=0
CHATFRAME_SHOW_ATTACHMENTS=0
CHATFRAME_SHOW_VOICE_INPUT=0
CHATFRAME_SHOW_VOICE_OUTPUT=0Web search and MCP keep running on every message (if their keys/configs are set) — the toggles are just hidden. Use CHATFRAME_SHOW_HEADER_ICON=0 / CHATFRAME_SHOW_HEADER_TITLE=0 to keep the bar but drop just the icon or title.
Drop a nodejs/config/custom.css file (next to chatframe.config.json in the mounted volume) and Chatframe injects it into every page's <head> after the built-in styles. Use it for custom fonts, per-area color overrides, or any CSS the deployment needs without rebuilding the image. Three dedicated classes are exposed for targeting:
.chatframe-header— top bar.chatframe-assistant-bubble— assistant message bubbles.chatframe-composer-pill— input composer
Each defaults to var(--surface) but reads from --header-bg / --assistant-bubble-bg / --composer-bg if you set them, so restyling one area doesn't drag the others along.
Example custom.css swapping the font and giving the header its own color:
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&display=swap');
:root {
--font-sans: 'Space Grotesk', system-ui, sans-serif;
--header-bg: #1a1a2e;
}Chatframe ships with four UI locales built-in: English, Spanish, French, German. The active language is resolved per-request:
- The
chatframe_localecookie (set when a user picks a language in Settings) wins. - Otherwise the
CHATFRAME_LOCALEenv var if it names a valid locale. - Otherwise English.
Server-side resolution means SSR renders directly in the chosen language — no hydration flash, no first-paint English-then-swap. The <html lang> attribute and voice STT/TTS recognizer locale all align to the active choice.
Branding fields in chatframe.config.json accept either a plain string (used for every locale, backward-compatible) or a per-locale object:
{
"welcomeMessage": {
"en": "Hi — I'm Chatframe. Ask me anything.",
"es": "Hola — soy Chatframe. Pregúntame lo que quieras.",
"de": "Hallo — ich bin Chatframe. Frag mich einfach."
},
"starterPrompts": {
"en": ["What's new in .NET 10?", "Explain Kubernetes pods"],
"es": ["¿Qué hay de nuevo en .NET 10?", "Explica los pods de Kubernetes"]
},
"defaultSystemPrompt": {
"en": "You are a helpful assistant.",
"es": "Eres un asistente útil."
},
"tagline": "Embeddable AI chatbot framework"
}Fields accepting this shape: welcomeMessage, starterPrompts, defaultSystemPrompt, tagline. Server resolves to the active locale, falling back to en then the first available variant if neither matches.
When the active UI locale is non-English, Chatframe automatically appends Respond in <language> unless the user writes in a different language. to the system prompt. So picking Spanish in the picker means the model answers in Spanish without any operator config. Opt out by setting CHATFRAME_LOCALE_HINT=0 (useful if your system prompt already handles language explicitly).
Drop a JSON file at nodejs/config/locales/<code>.json (e.g. it.json for Italian, ja.json for Japanese, pt-BR.json for Brazilian Portuguese). Same convention as custom.css and chatframe-mcp.json — file appears in the picker on the next page load, no rebuild.
The JSON is a flat string-to-string map. Keys mirror the t() calls in components; any missing key falls back to the English fallback embedded at the call site, so partial translations are fine.
Example nodejs/config/locales/ja.json covering the most visible strings:
{
"header.openChats": "チャットを開く",
"header.newChat": "新しいチャット",
"header.settings": "設定",
"composer.placeholder": "メッセージを送信…",
"composer.send": "送信",
"composer.voiceInput": "音声入力",
"settings.title": "設定",
"settings.language": "言語",
"settings.theme": "テーマ"
}A drop-in JSON file can also override any built-in key — useful for terminology tweaks per deployment without forking the image (e.g. rename "Assistant" to "Concierge" in your branded build).
To see all keys currently in use, grep the source: rg "t\(" nodejs/app | rg -o "t\('[^']+'" | sort -u.
If your deployment serves a single language and you don't want users switching, set CHATFRAME_LOCALE to your target language. The picker still appears in Settings — to hide it entirely you'd hide Settings (CHATFRAME_SHOW_SETTINGS=0). A separate kiosk flag for "language picker only" is a candidate future feature.
Chatframe ships no X-Frame-Options or frame-ancestors headers by default, so any page on any origin can embed it in an <iframe>. Drop the snippet below into your site:
<iframe
src="https://your-chatframe-host.example.com/"
style="width: 100%; height: 600px; border: 0; border-radius: 12px;"
title="AI chat"
allow="clipboard-write"
loading="lazy"
></iframe>For a bottom-right floating chat bubble (the typical support-widget pattern), wrap it in a fixed-position container:
<div style="position: fixed; bottom: 16px; right: 16px; width: min(380px, 100vw); height: min(640px, 80vh); z-index: 9999;">
<iframe
src="https://your-chatframe-host.example.com/"
style="width: 100%; height: 100%; border: 0; border-radius: 16px; box-shadow: 0 12px 40px rgba(0,0,0,0.18);"
title="AI chat"
allow="clipboard-write"
></iframe>
</div>Recommended kiosk flags for embedded use (docker/.env):
CHATFRAME_SHOW_HEADER=0 # no second app header inside your site's chrome
CHATFRAME_SHOW_SETTINGS=0 # don't expose provider/system-prompt settings
CHATFRAME_PERSIST_CHAT=0 # iframes get isolated localStorage anyway
CHATFRAME_SHOW_MODEL_PICKER=0 # force a fixed provider/model via CHATFRAME_PROVIDER+CHATFRAME_MODELSecurity notes:
- Chatframe is served over HTTPS in any real embed scenario — browsers refuse mixed-content iframes on HTTPS parent pages.
- The parent page's CSP must permit the Chatframe origin in
frame-srcif a strict CSP is in place. Example:Content-Security-Policy: frame-src https://your-chatframe-host.example.com; - Each browser device gets a JWT-signed
chatframe_tokencookie scoped to the Chatframe origin — iframes are isolated, so an embedded Chatframe has its own conversation history, separate from a direct visit to the same host. - To restrict who can embed Chatframe, add a
Content-Security-Policy: frame-ancestors 'self' https://your-customer.com;header at the Chatframe reverse proxy (Caddyheaderdirective). Default behaviour is allow-all because the "drop-in" positioning would be undermined by a default lockdown.
chatframe/
├── nodejs/ # the Next.js app
│ ├── app/ # routes + components
│ ├── lib/ # client + server helpers
│ ├── config/ # runtime config (mounted as a volume in Docker)
│ │ ├── chatframe.config.json # branding + themes + welcome + starter prompts
│ │ ├── chatframe-mcp.json # MCP server list
│ │ ├── custom.css # operator CSS overrides — optional
│ │ ├── locales/ # operator locale JSON files — optional
│ │ └── icons/ # PNGs served via /branding/*
│ └── package.json
├── docker/ # Dockerfile + three compose variants + Caddyfile
└── .github/workflows/ # GHCR multi-arch publish on `v*` tag
Next.js 15 App Router with React 19 + Tailwind. Server components SSR-render the shell and inject config into window.__CHATFRAME so first paint matches the branded config (no hydration mismatch when a fork rebrands). The chat API decouples the LLM run from the HTTP response — a background promise feeds events into an in-memory replay buffer, and the response stream is one of N possible consumers (the original POST /api/chat plus any GET /api/chat/replay/[id] reconnects with Last-Event-ID). MCP clients are long-lived per process; tool calls are namespaced by server id and dispatched at message time. JWT-signed device tokens scope each browser to its own conversations in localStorage.
Forked from cordfuse/mighty-ai-qr-web on 2026-06-23 because its chat UX was further along than any minimal-fork OSS starter. Stripped to a generic foundation, then iterated on the kiosk/embed angle. Git history was reset at v0.1.0 — the lineage stays as a credit, not as code archeology.
MIT. See LICENSE.





