Munin
Munin developer portal
Get a key →

Set up a chat widget

Lets an external AI agent running as a chat widget on a customer's website push transcripts into Munin's conversation module. Once the conversation is in Munin, a human in the dashboard can reply, and the customer's webhook receiver tells the external agent to step back.

1. Create the channel and mint a widget key

Call conv_create_widget_channel:

{
  "name": "storefront-bot",
  "originAllowlist": ["https://customer.example"]
}

Response includes widgetKey: "mn_widget_…" — shown once. Store it server-side.

originAllowlist is required — the widget ingest endpoint rejects any request whose Origin header doesn't match one of the listed full origins (scheme + host + port, exact match). List every environment that should be allowed to ingest (https://customer.example, https://staging.customer.example, etc.).

The widget key is bound to this channel via api_keys.channel_id. Rotate with conv_rotate_widget_key; update origins with conv_update_widget_channel.

Appearance attributes on the drop-in embed

If the customer uses Munin's own widget.js bundle rather than their own chat UI, these optional data-* attributes on the script tag configure it. Add them only when the operator asked for that look.

AttributeValuesEffect
data-munin-fontsbundled (default), inheritbundled ships subset Instrument Serif + JetBrains Mono (~60 KB) and matches the dashboard typography. inherit downloads no fonts and renders every string in whatever font-family the page applies to <body>, so the panel blends into the site's type stack. Sizes, weights and italics are unchanged either way.
data-munin-theme-colorhex, e.g. #0059DEAccent for the send button, focus rings, the email-save button, and the unread badge while the launcher keeps its default color. Text on top of it flips between ink and paper for contrast; on filled surfaces a mid-tone that could only carry ink text is darkened by at most 12% so it carries paper text.
data-munin-launcher-colorhexFill of the round launcher bubble. Defaults to the near-black ink of the panel header, so a brand-colored bubble is an explicit opt-in; the glyph inside follows for contrast. Once set, the unread badge inverts the bubble's colors (glyph color as fill, bubble color as the count), so it stays visible when the bubble matches the theme color.
data-munin-launcher-icon-colorhexColor of the chat glyph in the launcher, overriding the automatic contrast pick.
data-munin-header-colorhexFill of the panel's top bar (org name + close button). Defaults to the same near-black chrome as the launcher; the text/icon color picks whichever of ink/paper contrasts better.
data-munin-positionbottom-right (default), bottom-leftLauncher corner.
data-munin-sizecompact, standard (default), generousPanel size.
data-munin-org-namefree textHeader title. Defaults to "Chat".
data-munin-eyebrowfree textSmall uppercase label above the greeting.
data-munin-greetingfree textWelcome line; the widget renders everything after the first sentence in serif italic and a softer ink tone. Set the --munin-greeting-emphasis custom property to normal on the embed host for an upright second clause (see below).
data-munin-localeBCP-47 tagForces the widget's UI language instead of negotiating from the browser.
data-munin-color-schemeauto (default), light, darkauto follows the visitor's OS/browser preference (prefers-color-scheme) and updates live if they switch it; light/dark pins the panel regardless of OS setting. The launcher bubble, header bar and voice-call screen stay their fixed near-black chrome in every mode unless overridden by the color attributes above — only the panel body (welcome/chat/composer/cards) inverts.
data-munin-show-historytrue (default), falseWhether past conversations are listed on the welcome screen.
data-munin-cornerssquare (default), roundedCorner style for the panel, its controls and the launcher. square gives a square launcher with the unread badge centered on its top-right corner; rounded softens the panel and turns the launcher into a circle.
data-munin-nudgefree text, or emptyOpt-in teaser shown above the closed launcher: a short message, a dismiss button and an inline input. Leave the value empty for a localized default ("Hi. Got a question? Type it here and we'll answer right away."). Sending from the input opens the panel and posts the message as the first turn of a new conversation. While it shows, the launcher badge reads 1. It stays hidden when the panel has been opened or the current session already has messages, and for 7 days after the visitor dismisses it or opens the widget. On phone-sized screens (600px wide or less) it shows only the message and the dismiss button — tapping the message opens the full-screen chat — and it hides once the visitor scrolls most of a screen away, without snoozing. Max 280 chars.
data-munin-nudge-delayseconds, 0–3600How long after page load the nudge appears. Defaults to 8.
data-munin-nudge-colorhexFill of the nudge's message bubble. Defaults to a light tint of data-munin-theme-color (stronger in dark mode); the text on it flips between ink and paper for contrast.

An unrecognized value is a console warning, not an error — the widget falls back to the default and still mounts.

Visitor attributes on the drop-in embed

The same bundle accepts a visitor profile, which lands on the contact row the first time the session ingests. Render these for signed-in users on a server-rendered page — they are the embed-side equivalent of the visitor object in §2's server-to-server payload.

AttributeEffect
data-munin-visitor-nameDisplay name, trimmed and truncated to 120 chars.
data-munin-visitor-emailEmail address, format-checked client-side and re-validated server-side. An invalid value is dropped with a console warning rather than sent.
data-munin-visitor-metaFlat JSON object of string / number / boolean values, max 4 KB, e.g. '{"plan":"pro","accountId":"acc_42"}'. Lands on conv_contacts.metadata. Nested values are dropped with a warning.
data-munin-meta-<key>Shorthand for a single metadata entry; the key is camelized (data-munin-meta-account-id → accountId). Merged with data-munin-visitor-meta, which wins on a key collision.

Send a name whenever you have one. Identity verification (§4) binds a session to an externalId and, if you sign one, an email — never a name — so a verified visitor with no data-munin-visitor-name still has an unnamed contact row. Every surface that displays a customer falls back through name → email → phone, so without a name the dashboard, the Slack mirror and outreach all show a raw email address, or a generic placeholder when there is no email either.

These are unrelated to data-external-id / data-user-hash: the visitor attributes are unverified page-supplied claims, useful for display, while identity verification is what actually authenticates the session. Sending both is the normal case for a signed-in user.

Overriding styles from the page

The panel renders inside a shadow root, so the site's own stylesheets cannot reach its internals — but CSS custom properties do inherit across the shadow boundary. Set them on the widget host element from an ordinary page stylesheet:

[data-munin-widget] {
  --munin-greeting-emphasis: normal;
}

--munin-greeting-emphasis styles the part of the greeting after the first sentence; it takes any font-style value and defaults to italic. This is the only supported way to restyle panel internals — reaching into .shadowRoot from JavaScript depends on class names that change between releases.

Programmatic open/close

Once the widget script has executed, window.mn.widget exposes:

window.mn.widget.open();     // opens the panel
window.mn.widget.close();    // closes the panel
window.mn.widget.toggle();   // flips it
window.mn.widget.isOpen();   // current state, boolean
window.mn.widget.ready;      // true once the namespace is installed
await window.mn.widget.identify(externalId, userHash);  // see §4
await window.mn.widget.call();     // opens the panel and starts a voice call
await window.mn.widget.endCall();  // hangs up the active call

Wire a "Chat with us" link anywhere in the page's own nav/footer to window.mn.widget.toggle() instead of relying on the launcher bubble alone. Because the script tag has defer, window.mn.widget isn't installed until after the page has parsed — safe to call from a click handler, not safe to call synchronously in an inline <script> above the widget tag.

For code that must run as soon as the widget is usable — an identify call on a freshly signed-in SPA, say — gate on the namespace rather than polling. The widget dispatches munin:widget-ready on document once it mounts:

const go = () => window.mn.widget.identify(externalId, userHash);

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

The flag closes the listener-attached-too-late race: if the widget mounted before your code ran, the event has already fired, but the flag says it is safe to call now. (munin:widget-ready is the widget's own signal — the analytics tracker fires munin:analytics-ready separately.)

Link a voice channel

The widget's call button and window.mn.widget.call() connect the visitor to a Vapi or Threll voice channel. Which one is decided on the server, from the widget channel's voiceChannelId:

  • Unset — the call goes to the organization's only active voice channel. With none, calls report no_active_voice_channel; with two or more, they report multiple_voice_channels_without_widget_routing rather than picking one at random.
  • Set — the call always goes to that channel. If it has since been deactivated or deleted, calls report widget_voice_channel_id_not_found_or_inactive.

So once an organization has more than one voice channel, every widget that should take calls needs one linked. Find the id with conv_list_channels (a voice channel with vendor vapi or threll), then set it:

{
  "name": "conv_update_widget_channel",
  "arguments": { "channelId": "<widget channel id>", "voiceChannelId": "<voice channel id>" }
}

conv_create_widget_channel takes the same voiceChannelId argument. Pass "voiceChannelId": null to the update to unlink and fall back to the single-channel rule; leaving it out keeps the current link. Anything other than a Vapi or Threll voice channel in the same organization — including a deleted one — is refused with conv_widget_voice_channel_invalid; a deactivated channel can be linked, but takes no calls until it is reactivated. Dashboard users can set the same link under Channels → the widget's Edit → Voice channel.

Start a voice call from the page

When the widget channel has a voice channel linked, the page can skip the chat step and put the visitor straight into a call — a "Call us" button in the hero, a phone icon in the header. The quickest way is a data-munin-call attribute on any element; no script needed:

<button type="button" data-munin-call>Talk to us</button>

A click on it, or on anything inside it, opens the panel and starts the call. The widget also calls preventDefault() on the click, so a link will not navigate. For more control, call window.mn.widget.call() from your own click handler. It resolves to { started: true } once the call is connecting, or to { started: false, reason } when it could not start:

reasonMeaning
already_in_callA call is already running; the panel is brought forward instead of starting a second one.
conversation_failedThe widget could not create the conversation to attach the call to.
request_failedThe voice start request did not reach the backend.
session_failedThe browser could not set up the call — usually microphone permission denied.
anything elseThe server's reason the channel cannot take a call right now — one of the voice-routing reasons under Link a voice channel, or a vendor failure.
document.getElementById('call-us').addEventListener('click', async () => {
  const result = await window.mn.widget.call();
  if (!result.started) showPhoneNumberInstead();
});

The call joins the visitor's current conversation, or starts a new one if there is none, so the transcript sits in the same thread as any earlier chat. Trigger it from a click: the browser asks for microphone permission when the call starts. A data-munin-call element does nothing until the widget script has run, so a click during page load is lost rather than queued.

window.mn.widget is a single global, so on a page with two widget embeds it stays bound to whichever mounted first and the second logs a warning. Don't rely on it when you deliberately run two channels on one page — drive those from their own launchers.

2. Push transcripts from the agent

POST /v1/widget/messages — server-to-server is the recommended integration so the key never reaches browser JS.

curl -sS https://munin.example/v1/widget/messages \
  -H "Authorization: Bearer $MUNIN_WIDGET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "channelId": "cch_…",
    "sessionId": "vis_abc123",
    "visitor": { "name": "Vita", "email": "vita@example.com" },
    "url": "https://customer.example/checkout",
    "messages": [
      { "role": "end_user", "body": "Where is my order?", "providerMessageId": "evt_1" },
      { "role": "agent",     "body": "Let me check…",      "providerMessageId": "evt_2" }
    ]
  }'

Response: { conversationId, displayId, contactId, inserted, skipped }.

Conversation upsert

Conversations are keyed by (orgId, channelId, metadata.sessionId). Sending the same sessionId again appends to the existing conversation; a new sessionId opens a new one.

Idempotency

If you set providerMessageId on a message, replays of the same identifier are silently skipped (counted as skipped). Without providerMessageId, every POST inserts new rows — that's by design (the agent opts into idempotency by including the field).

Visitor enrichment

visitor.email enables CRM linkage: the contact is matched on (org, email). If you don't have an email, the contact is matched on metadata.sessionId so re-pushes update the same row. Once the visitor identifies themselves, send the email — the existing contact gets enriched rather than duplicated.

Send visitor.name too whenever you know it. It is what every customer-facing surface displays first, and no other part of the pipeline can infer it — see the note under §1's visitor attributes. On the drop-in embed, the same three fields are data-munin-visitor-name / -email / -meta.

Attaching images

A visitor message can carry up to 10 images. Upload each one first, then name the ids on the message.

  1. POST /v1/widget/attachments with { channelId, conversationId, sessionId, name, mime, sizeBytes } returns { id, uploadUrl, uploadMethod, uploadFields, uploadExpiresAt }.
  2. PUT (or POST, per uploadMethod) the bytes to uploadUrl.
  3. POST /v1/widget/attachments/<id>/complete with { channelId, conversationId, sessionId } confirms the bytes, derives thumbnails, and returns the attachment with its signed url and thumbnailUrl.
  4. POST /v1/widget/messages with attachmentIds: ["<id>", …] on the message.

conversationId comes from POST /v1/widget/conversations (or from any earlier ingest response), and the sessionId must be the one that owns that conversation — an upload can only be completed, and only be attached to a message, by the session that requested it. Accepted types are PNG, JPEG, GIF and WebP, at most 10 MB each; SVG is refused outright because it can carry inline scripts. A session may hold at most 10 uploads that are not yet on a message.

A message with attachmentIds may leave body empty; a message with neither body nor attachments is rejected.

GET /v1/widget/messages returns an attachments array on every message that has one — inbound visitor images and outbound agent/human images alike — each with url, thumbnailUrl (a smaller webp variant), width, height and deleted. Both URLs are short-lived signed links, valid for an hour, and the serve route re-checks the row on every request, so refetch rather than caching them. A deleted attachment comes back as deleted: true with url and thumbnailUrl set to null while keeping its name — render a placeholder, never a broken image.

Errors carry a machine-readable code: conv_attachment_mime_rejected, conv_attachment_too_large, conv_attachment_too_many, conv_attachment_upload_missing, conv_attachment_size_mismatch, conv_attachment_conflict, conv_attachment_deleted.

3. Receive replies from a human / Munin agent

When a Munin user replies in the conversation UI, conversation.message.sent fires on every webhook subscribed to that event. Subscribe your endpoint and:

  1. Fetch the message via the standard conv_get_conversation tool.
  2. Render it in the customer-side widget UI.
  3. Optionally signal your AI to step back so the human owns the thread.

Same webhook surface used elsewhere in Munin — no widget-specific events.

4. Verified identity (optional)

By default widget visitors are anonymous — contacts are keyed on metadata.sessionId (or visitor.email if sent). To tie a widget session to a known user (and gate anonymous access), attach a signed identity: a verifiedExternalId + userHash pair the ingest endpoint verifies against the channel's identity-verification secret.

conv_create_widget_channel returns identityVerificationSecret once (alongside widgetKey). Treat it like an OAuth client secret — store it server-side, never embed it in browser JS. Rotate with conv_rotate_widget_identity_secret (previously-issued hashes stop verifying immediately). This is a separate secret from the analytics tracker's — sign widget hashes with the widget channel's secret, never the tracker secret.

Compute the hash server-side:

import { createHmac } from 'node:crypto';

function userHash(externalId: string, secret: string): string {
  return createHmac('sha256', secret).update(externalId).digest('hex');
}

The widget hash covers externalId only — no visitor binding. That's a deliberate contrast with the analytics tracker, whose identify hash binds the visitor and the email (a length-prefixed mn.identity.v1 payload, see skill://analytics/identify-visitors) and therefore needs a per-session browser round-trip. Because the widget hash is static per user, you can server-render it into the embed with no round-trip:

<script async
  src="https://munin.example/widget.js"
  data-widget-key="mn_widget_…"
  data-channel-id="cch_…"
  data-external-id="user_42"
  data-user-hash="<hex hmac from above>">
</script>

data-external-id and data-user-hash are all-or-nothing: sending one without the other is rejected (identity_partial). Render them only for signed-in users; omit both for anonymous visitors. (On browser-direct calls, the same values are passed as the verifiedExternalId + userHash params.)

Signing the email too

data-munin-visitor-email is a claim the page makes, so Munin treats it as self-reported: the agent may show it but won't use it to look up the customer's orders or bookings. If your backend knows the signed-in user's email, sign it into the hash instead and the email becomes as trustworthy as the externalId. That is what lets the agent look up the customer's orders and bookings, and book, change or cancel on their behalf, from the widget.

The signed payload is length-prefixed so no value can be shifted across a field boundary: each of ['mn.widget-identity.v1', externalId, email] is written as its UTF-8 byte length, a colon, then the value, and the three are concatenated. Lowercase and trim the email before signing.

import { createHmac } from 'node:crypto';

function userHashWithEmail(externalId: string, email: string, secret: string): string {
  const payload = ['mn.widget-identity.v1', externalId, email.trim().toLowerCase()]
    .map((field) => `${Buffer.byteLength(field, 'utf8')}:${field}`)
    .join('');
  return createHmac('sha256', secret).update(payload).digest('hex');
}

Render it with data-verified-email next to the pair, or pass it to window.mn.widget.identify(externalId, userHash, { email }):

<script async
  src="https://munin.example/widget.js"
  data-widget-key="mn_widget_…"
  data-channel-id="cch_…"
  data-external-id="user_42"
  data-verified-email="ola@example.test"
  data-user-hash="<hex hmac over the payload above>">
</script>

The email and the hash travel together: a hash computed over the externalId alone does not verify once an email is attached, and a hash signed for one email does not verify with another (identity_verification_failed). data-verified-email without the pair is rejected as identity_partial. On browser-direct calls the field is verifiedEmail, or the x-munin-verified-email header on GETs.

Munin binds the signed email to the signed-in user's end-user record, replacing an email the visitor had typed. It leaves the record alone when another end user already holds that address, so that session gets no email until you merge the two in the dashboard. A leaked hash lets someone act as that user until you rotate the secret, and with a signed email that includes reading their orders and changing their bookings, so keep the hash out of logs and caches like any session credential.

Set requireVerifiedIdentity: true on the channel (conv_create_widget_channel / conv_update_widget_channel) to reject unverified sessions outright; the default (false) allows anonymous ingest alongside verified ones.

Because the widget and the analytics tracker share the same localStorage visitor id (mn.vid), identifying a visitor to the widget also stitches their prior anonymous analytics history — no separate window.mn.analytics.identify call needed for that visitor.

The same pair authorizes the realtime socket

The identity is verified twice: once on every HTTP call, and once on the WebSocket handshake that streams agent replies (wss://<api-host>/v1/realtime). The socket takes them as query params — externalId (or verifiedExternalId, both accepted), userHash, and verifiedEmail when you sign one — against the same channel secret and the same payload as the HTTP calls. The bundled widget does this for you; you only need the names when you drive /v1/realtime yourself.

Every rejection is answered on the handshake as 403 with the reason in both an X-Munin-Error header and a {"code":"…"} body: identity_partial (one of the two params missing), identity_verification_failed (hash does not match this channel's secret), identity_required (requireVerifiedIdentity channel, no identity offered), origin_required / origin_not_allowed. A 401 means the widget key itself was not accepted, before identity was ever considered. Browsers cannot read a failed handshake's status, so reach for curl -i or a Node client when diagnosing one.

The secret is per channel, not per org. An org with a dev channel and a prod channel has two channelIds, two widget keys and two identity secrets; a single WIDGET_IDENTITY_SECRET in your app can only match one of them. Signing with the other channel's secret is the usual cause of a widget that sends fine but never receives: put the channel id in the env var name, or read it from the same config that supplies data-channel-id.

If the socket is rejected three handshakes in a row, the widget drops the identity params and reconnects anonymously — replies keep streaming, scoped to the session rather than the user, with a console warning naming the mismatch. It re-arms the identity on the next window.mn.widget.identify call or page load, so fixing the secret needs no code change.

Running the widget and the analytics tracker on the same page

Both bundles hang off window.mn, but each owns its own namespace — mn.widget and mn.analytics — so nothing collides. A page running both identifies each surface explicitly:

window.mn.widget.identify(externalId, widgetHash);                 // externalId-only hash
window.mn.analytics.identify(externalId, trackerHash, { email });  // visitor-bound hash

They take different hashes signed with different secrets (see the contrast above), so passing the same value to both will fail one of them. On a server-rendered page, drop the first call and use data-external-id + data-user-hash on the embed instead.

window.mn.widget.identify earns its keep on an SPA that signs a user in without a reload: the widget has already mounted anonymously, and this is the call that claims that anonymous chat session — transcript and all — for the now-known user.

Migrating from 4.x: the widget's identify used to sit on the shared root as window.mn.identify, where it chained with the tracker's identically-named call and one of the two always rejected the hash it received. It is now window.mn.widget.identify.

Sharing a session across subdomains

The visitor id and session id live in localStorage (with a cookie fallback), both scoped to the exact host by default. A conversation started on www.example.com therefore does not carry over to app.example.com. To share one thread across sibling subdomains — e.g. an anonymous chat on the marketing site that continues (and gets claimed) once the visitor signs in on the app — set data-munin-cookie-domain to a shared parent domain on every embed:

<script async
  src="https://munin.example/widget.js"
  data-widget-key="mn_widget_…"
  data-channel-id="cch_…"
  data-munin-cookie-domain=".example.com">
</script>

The session + visitor cookies are then written with that Domain, so both subdomains read the same ids and the anonymous thread is claimed on identify. The value must be a suffix of the current host (.example.com on app.example.com); anything else is ignored client-side to avoid the browser silently dropping the cookie.

5. Browser-direct integration (less secure)

If you must call the endpoint from browser JS, the channel's originAllowlist reflects allowed Origin headers and the endpoint sets the matching Access-Control-Allow-Origin. Anyone on a listed origin can use the key; rotation is one tool call. Server-side is strongly preferred.

6. Operations

TaskHow
Disable the channelSet conv_channels.active=false. Existing keys still auth but ingest returns 403.
Rotate the widget keyconv_rotate_widget_key. Old key revoked; existing inflight requests with it 401.
Rotate the identity secretconv_rotate_widget_identity_secret. Previously-issued data-user-hash values stop verifying; re-render signed-in pages with freshly-computed hashes.
Tighten originAllowlistconv_update_widget_channel.
Route calls to a different voice channelconv_update_widget_channel with voiceChannelId (see Link a voice channel).
Inspect a conversationStandard conv_* tools. The metadata.sessionId, metadata.providerMessageId, and metadata.url fields tell you the visitor's session.