Munin
Munin developer portal
Get a key →
Guide · Embeds

Drop-in chat widget.

One <script> tag on your site gives visitors a chat launcher that opens a Munin conversation. The widget runs inside a Shadow DOM, so your host page’s CSS can’t bleed in and ours can’t bleed out.

Quick start paste & ship

Mint a widget key from Settings → Channels, pick the channel, copy the embed snippet, paste it on every page where the launcher should appear.

Embed snippetpaste in <body>
<script src="https://api.getmunin.com/widget.js"
        data-munin-host="https://api.getmunin.com"
        data-widget-key="mn_widget_…"
        data-channel-id="cch_…"
        data-munin-fonts="inherit"
        defer></script>

Required attributes

data-munin-host
Origin of your Munin backend. The widget calls the REST API and the realtime WebSocket against this host. No trailing slash.
data-widget-key
Channel-bound API key starting with mn_widget_. Shown once when you create or rotate a widget channel. Safe to embed on the public page — the key only authorizes the channel it was minted for.
data-channel-id
The channel the visitor will talk to. Must match the channel the widget key was minted for.

Optional attributes

Add any of these to the script tag to customize the widget.

data-munin-fonts
"bundled" (default) ships subset Instrument Serif + JetBrains Mono with the widget — adds ~60 KB and matches the dashboard typography pixel-for-pixel. Set to "inherit" to download no fonts and render every string in whatever font-family your page already applies to <body>, so the panel blends into your own type stack. Sizes, weights and italics stay as designed either way.
data-munin-org-name
Header title shown in the panel. Defaults to "Chat".
data-munin-eyebrow
Small uppercase label above the welcome greeting, e.g. “Acme Support · powered by Munin”.
data-munin-theme-color
Hex accent color for the unread badge, send button, links, and visitor bubbles. Defaults to #0066FF. Text drawn on top of it flips between ink and paper automatically, whichever contrasts better.
data-munin-launcher-color
Hex fill of the round launcher bubble. Defaults to the widget’s near-black chrome tone (fixed regardless of light/dark mode), so a brand-colored bubble is an explicit opt-in. The chat glyph inside follows with whichever of ink/paper contrasts better.
data-munin-launcher-icon-color
Hex color of the chat glyph inside the launcher, overriding the automatic contrast pick. Use it on its own to recolor the glyph while keeping the default bubble.
data-munin-header-color
Hex fill of the panel’s top bar (org name + close button). Defaults to the same near-black chrome as the launcher; the text/icon color follows the same automatic contrast pick.
data-munin-color-scheme
"auto" (default) follows the visitor’s OS/browser preference and updates live if they flip it; "light" or "dark" pins the panel regardless of their OS setting. The launcher bubble, header bar and voice-call screen keep their fixed near-black chrome in every mode unless you set the color attributes above — only the panel body (welcome/chat/composer/cards) inverts.
data-munin-locale
Pins the interface language. Left off, the widget negotiates from the browser — navigator.language, then each entry of navigator.languages in order — and falls back to English. Twenty-one languages ship with the bundle: en, nb, nn, da, sv, fi, is, et, lv, lt, de, fr, es, it, pt, nl, pl, cs, sk, hu, ro. Region subtags are dropped, so "de-AT" resolves to de and "pt-BR" to pt. Norwegian keeps both written standards: "no" resolves to Bokmål and "nn" is served Nynorsk. A tag with no translation is ignored silently rather than erroring, so the visitor lands on their browser language or English.
data-munin-position
"bottom-right" (default) or "bottom-left".
data-munin-size
Panel size: "compact", "standard" (default), or "generous".
data-munin-greeting
First line on the welcome screen. The widget splits on the first sentence so the second clause renders in italic, matching “Hi there. How can we help?”. Set --munin-greeting-emphasis: normal on [data-munin-widget] from your own stylesheet to keep it upright.
data-munin-show-history
Set to "false" to hide the past-conversation list on the welcome screen.

Whichever language wins travels with the conversation when it starts: the agent is asked to greet in it, and the runtime’s canned messages (opening greeting, handover notice) use it when the model is unavailable. Treat it as a hint about the visitor rather than a constraint on the reply — the model answers in whatever language they write in.

Identity verification optional

If you want to bind a chat thread to a known user (so they resume their conversation on the next visit, even from a different device), compute a server-side HMAC with the channel’s identity secret and pass it as data-user-hash:

Node.jscompute on every request
import crypto from 'node:crypto';
const userHash = crypto
  .createHmac('sha256', process.env.MUNIN_IDENTITY_SECRET)
  .update(externalId)
  .digest('hex');

Then on the script tag:

With identityvisitor binds to externalId
<script src="https://api.getmunin.com/widget.js"
        data-munin-host="https://api.getmunin.com"
        data-widget-key="mn_widget_…"
        data-channel-id="cch_…"
        data-external-id="user_42"
        data-user-hash="<hex digest>"
        defer></script>

Without identity, the widget identifies the visitor by a UUID kept in localStorage (with a cookie fallback) — refreshes resume the same thread, but a different browser starts fresh.

Both localStorage and the fallback cookie are scoped to the exact host by default, so a conversation started on www.example.com does not carry over to app.example.com. To share one thread across sibling subdomains, set data-munin-cookie-domain=".example.com" on every page’s embed — the session and visitor cookies are then written with that Domain and the anonymous thread is claimed when the visitor signs in on the app. The value must be a suffix of the page’s host, or it’s ignored.

Identify after script load (SPAs)

If sign-in happens after the widget loads — typical for single-page apps where login is a route change, not a full reload — call window.mn.widget.identify(externalId, userHash) once the user is known. The widget POSTs to /v1/widget/identify, reconnects its WebSocket under the new identity, and the backend migrates the current chat: the anonymous end-user becomes the verified one, the contact’s externalId is updated, and the conversation history stays put.

Browserafter the user signs in
// userHash is the same server-signed HMAC as data-user-hash above —
// compute it once the externalId is known and hand it to the widget.
const go = () => window.mn.widget.identify(externalId, userHash);

window.mn?.widget?.ready
  ? go()
  : document.addEventListener('munin:widget-ready', go, { once: true });

The widget installs its namespace when it mounts and fires munin:widget-ready, so gate on window.mn.widget.ready or that event rather than polling. The analytics tracker has its own window.mn.analytics.identify() with a different hash and a different secret — the two never share a call. Idempotent — calling it twice with the same externalId is a no-op. Calling it with a different externalId on a session that’s already verified returns 403; mint a fresh session if you genuinely need to swap identities mid-flight.

Programmatic open/close

Once the deferred script has executed, window.mn.widget exposes open(), close(), toggle(), and isOpen() so you can drive the panel from your own nav, a “Chat with us” link, or a proactive prompt on a timer — instead of relying on the launcher bubble alone.

Browseranywhere after the script tag runs
document.getElementById('chat-with-us').addEventListener('click', () => {
  window.mn.widget.toggle();
});

The script tag has defer, so window.mn.widget isn’t installed until the page has parsed — safe to call from a click handler, not safe to call synchronously from an inline <script> placed above the widget tag. It’s a single global: on a page with two widget embeds it stays bound to whichever mounted first, and the second logs a console warning rather than silently stealing it.

Visitor profile

Pre-populate the visitor’s name, email, and arbitrary metadata so they show up immediately on the contact row. Useful for logged-in customers.

data-munin-visitor-name
Display name, max 120 chars.
data-munin-visitor-email
Email address. Validated client-side; re-validated by the server on every request.
data-munin-visitor-meta
Flat JSON object of string/number/boolean key-values, max 4 KB, e.g. '{"plan":"pro","accountId":"acc_42"}'. Lands on conv_contacts.metadata.
data-munin-meta-<key>
Sugar form of the above — every data-munin-meta-* attribute becomes a metadata key. data-munin-meta-plan="pro"{"plan":"pro"}.

What it does

Welcome screen
Shown on launcher open: greeting, “Start a conversation” CTA, and the visitor’s past conversations. Identity-verified visitors see every thread bound to their externalId; anonymous visitors see only threads from session-IDs remembered locally.
AI greeting
When the visitor clicks “Start a conversation”, the widget creates the thread server-side and the AI runner generates an opening turn from the system prompt. The visitor sees the three-dot indicator while the LLM is working, then the greeting lands as a real agent message stored in the conversation.
Email capture
After the first agent turn, an inline card prompts the visitor to share their email so the operator can follow up if the visitor closes the tab. Submitted via PATCH /v1/widget/visitor, persisted on both conv_contacts and end_users.
Typing indicator
The runner emits realtime typing events while it’s generating, with a 3-second keepalive so the indicator stays alive through long replies. Server auto-clears after 5 seconds of silence; widget auto-clears locally after 5 seconds as a fallback.
Handover to a human
When an operator takes the conversation (manual claim or agent-requested escalation), the widget’s chat subtitle flips from “Munin AI · instant” to the operator’s name, and subsequent agent bubbles are tagged human instead of AI.

Security

Origin allowlist
Each widget channel has an originAllowlist. The widget’s requests carry an Origin header; the server rejects requests from any origin not on the list. No allowlist = allow all origins (useful for staging). Set it before going to production.
Identity verification
The HMAC pairs an externalId to a digest signed with the channel’s identity secret. Without this, a visitor can’t claim someone else’s identity — they get an anonymous session bound to their local sessionId.
Require verified identity
Toggle on the channel config to reject anonymous traffic entirely. Useful for in-app embeds where every visitor is signed in.
Shadow DOM
The widget tree lives in an open shadow root. Host-page CSS doesn’t reach in, and the widget’s styles don’t reach out. Custom fonts are registered at the document level so they cross the shadow boundary cleanly.