Skip to content

cordfuse/chatframe

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

53 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Chatframe

version license

Welcome + starter prompts
Welcome bubble with four clickable starter prompts
Streaming chat response
Chat showing a user question and the assistant's streamed response
Conversation history
Left drawer listing past conversations
Settings panel
Settings panel with theme picker, provider, system prompt, temperature, data tools
Kiosk mode — gear visible
Kiosk mode rebranded as ACME Inc. with custom theme; gear icon still visible
Full kiosk — gear also hidden
Full kiosk mode: same app with the gear icon also hidden via CHATFRAME_SHOW_SETTINGS=0

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.

The problem

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.

The solution

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.

Features

  • 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 TavilyCHATFRAME_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 to native or tavily to force one.
  • Resumable streams — server keeps a 5-minute replay buffer; clients reconnect via Last-Event-ID after 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 a config/custom.css for 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=0 for 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 a config/locales/<code>.json to add any other language without a rebuild. See Internationalisation.
  • Embeddable — drop an <iframe> into any page; no X-Frame-Options by 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).

Quick start (Docker)

cd docker/
cp .env.example .env
# Edit .env — at minimum set JWT_SECRET and one provider API key
docker compose up --build
# → http://localhost:3008

The 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).

Quick start (bare-metal Node)

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:3000

Configuration

All operator config lives in two places:

  • Secrets + flags → env vars (Docker .env or bare-metal .env.local)
  • Branding + themes + MCP servers + iconsnodejs/config/ (the persistent volume mount in Docker setups)

Provider API keys

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"]).

Operator env vars

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.

Branding (config/chatframe.config.json)

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 container
  • surface-2 → form inputs, search bar, hover states, table headers
  • surface-3 → deeper hover states inside dropdowns
  • primary → send button, links, scrollbar thumb, active-state highlights
  • on-primary → text on top of primary (e.g. send-icon color)
  • fg → main body text on surfaces
  • fg-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.

MCP servers (config/chatframe-mcp.json)

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:

  • httptype (required), url (required), label (optional, shown in the MCP picker; defaults to the server id)
  • stdiotype (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.

Kiosk mode

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=0

Web 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.

Custom CSS

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;
}

Internationalisation

Chatframe ships with four UI locales built-in: English, Spanish, French, German. The active language is resolved per-request:

  1. The chatframe_locale cookie (set when a user picks a language in Settings) wins.
  2. Otherwise the CHATFRAME_LOCALE env var if it names a valid locale.
  3. 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.

Localizing operator content

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.

AI replies in the user's language

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).

Adding a new language

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.

Disabling the language picker

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.

Embedding (iframe)

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_MODEL

Security 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-src if 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_token cookie 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 (Caddy header directive). Default behaviour is allow-all because the "drop-in" positioning would be undermined by a default lockdown.

Repo layout

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

Architecture (one paragraph)

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.

Provenance

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.

License

MIT. See LICENSE.

About

Embeddable AI chatbot framework — kiosk mode, drop-in branding, MCP-ready, 12 LLM providers

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages