Munin
Munin developer portal
Get a key →

Frontend integration (Conv widget + Analytics + CMS)

You're a coding agent setting up a frontend that talks to an existing Munin tenant: chat widget bubble, page-view tracker, and live blog/article content from the CMS. Every Munin-shaped surface in the page collapses into one of three integrations:

  • Chat widget — drop-in <script> from https://api.getmunin.com/widget.js. Adds the bubble UI.
  • Analytics tracker — drop-in <script> from https://api.getmunin.com/tracker.js. Records page views.
  • CMS delivery — anonymous GET https://api.getmunin.com/v1/cms/{{ORG_ID}}/{collectionSlug} JSON for live content.

Each has a sibling skill with the canonical details:

  • skill://conv/setup-chat-widget
  • skill://analytics/track-website-traffic
  • skill://cms/migrate-content (for authoring content; this playbook is about fetching it)

This playbook exists because coding-agent platforms (Lovable, Bolt, Replit, v0, Claude Code, Cursor, …) hit the same handful of misconfigurations every time. Read this once before scaffolding so you don't burn three turns guessing.

Before you start — API_URL, org id, and preview origin(s)

You can mint widget keys and tracker keys yourself via MCP. API_URL and your org id are already known — only the preview origin must come from the operator.

1. API_URL — the Munin API origin

One origin serves /widget.js, /tracker.js and /v1/cms/*. The MCP server has already told you this value — it's stated in the server instructions you received on connect, and every https://api.getmunin.com in this playbook has been substituted with it. Use it as-is; do not ask the operator and do not guess. It differs per deployment, so no host is a safe default.

The /mcp endpoint you are connected to may be on a different host — some deployments put the API and MCP on separate subdomains. Never derive API_URL from your MCP connection URL; take it from the server instructions.

If for some reason the value is still the literal https://api.getmunin.com (an old client that doesn't substitute), fall back to the operator's env (MUNIN_API_URL, NEXT_PUBLIC_API_URL, VITE_API_URL, API_URL) and only ask as a last resort.

Write it into the frontend's env file under the name the framework reads at build time, not hardcoded into source. Conventions by framework:

FrameworkEnv var nameRead in code as
Next.jsNEXT_PUBLIC_API_URLprocess.env.NEXT_PUBLIC_API_URL
Vite (React/Vue/Svelte)VITE_API_URLimport.meta.env.VITE_API_URL
Remix / React RouterAPI_URL (server) + loader-pass to clientprocess.env.API_URL server-side
AstroPUBLIC_API_URLimport.meta.env.PUBLIC_API_URL
Node / SvelteKit serverAPI_URLprocess.env.API_URL

Fail loudly at startup if the var is unset. Do not provide a fallback default — silent fallbacks hide misconfiguration.

2. Preview + production origins

For the widget and tracker allowlists below: the full origin(s) the running frontend will be served from. Coding-agent platforms generate subdomains like https://*.lovable.app, https://*.lovableproject.com, https://*.replit.dev, https://*.vercel.app, https://*.netlify.app. Ask the operator for the current preview URL and copy the origin exactly (scheme + host, no path, no wildcards — allowlists do exact-match comparison).

Also ask for the production origin so you can list both up front. Adding more later is one MCP call (conv_update_widget_channel / analytics_update_tracker) but you'll save a round-trip by getting them now.

If the operator already minted keys, ask for those instead and skip the create steps — they may have provisioned the channel for a different reason.

Step 1 — chat widget

1a. Create the channel + mint a key

{
  "name": "conv_create_widget_channel",
  "arguments": {
    "name": "<customer site name>",
    "originAllowlist": [
      "https://abc123.lovable.app",
      "https://app.customer.example"
    ]
  }
}

Response includes channelId and widgetKey: "mn_widget_…". Both go into the script tag — neither is secret (the widget key is meant to be in browser source); the origin allowlist is the security boundary, not the key.

originAllowlist is a list of full origins (scheme + host + port). No wildcards, no paths. https://customer.example and https://customer.example/app are not the same; https://customer.example and http://customer.example are not the same. Match the browser's Origin header exactly.

Empty-allowlist behavior depends on MUNIN_WIDGET_REQUIRE_ALLOWLIST:

  • Unset / 0 / false (the default) — empty originAllowlist accepts requests from any origin. Fine for local development and getting a preview running quickly; not safe for production.
  • MUNIN_WIDGET_REQUIRE_ALLOWLIST=1 (recommended for production) — empty allowlist fails closed: ingest returns 403 origin_allowlist_required until at least one origin is configured.

If you don't have the preview URL yet, creating the channel with originAllowlist: [] will work while the env var is unset — but populate it as soon as you know the origin, both so the smoke test exercises the real path and so the channel is safe to promote to prod without changes.

1b. Embed the script

Read API_URL from env at render/build time and inject it into both the src and data-munin-host attributes. Example (Vite/React):

const API_URL = import.meta.env.VITE_API_URL;

<script async
  src={`${API_URL}/widget.js`}
  data-munin-host={API_URL}
  data-widget-key="mn_widget_…"
  data-channel-id="cch_…"
/>

Plain HTML form (after string-substituting API_URL from env at build time):

<script async
  src="https://api.getmunin.com/widget.js"
  data-munin-host="https://api.getmunin.com"
  data-widget-key="mn_widget_…"
  data-channel-id="cch_…">
</script>

All three data-* attributes are required. The script tag's src and the data-munin-host value should normally be the same origin.

Optional attributes (greeting text, theme color, identified-user HMAC, visitor metadata) are documented in skill://conv/setup-chat-widget. Don't add them unless the operator asked.

1c. SPA route changes

The widget itself doesn't care about route changes — it's a fixed-position bubble that overlays the page. Nothing extra to wire.

Step 2 — analytics tracker

2a. Mint a tracker key

{
  "name": "analytics_create_tracker",
  "arguments": {
    "name": "<customer site name>",
    "allowedOrigins": [
      "https://abc123.lovable.app",
      "https://app.customer.example"
    ]
  }
}

Response includes trackerKey: "mn_track_…" (shown once — capture it). Same origin allowlisting story as the widget: empty allowedOrigins accepts any origin by default; setting MUNIN_TRACKER_REQUIRE_ALLOWLIST=1 on the backend makes it fail closed instead. Production deployments should run with the env var on.

2b. Embed the script

const API_URL = import.meta.env.VITE_API_URL;

<script async
  src={`${API_URL}/tracker.js`}
  data-key="mn_track_…"
/>

The tracker only needs the API origin in its src — there's no separate data-api required unless you're serving the bundle from a different host than the API (uncommon; only set if you actually have a CDN-fronted tracker.js).

The only required attribute is data-key. The script auto-fires a page view on DOMContentLoaded, tracks history.pushState / replaceState / popstate route changes as further views, and reports scroll depth and dwell time on exit — no flags for any of it.

data-api is optional and defaults to the script's origin. Don't set it unless the API origin differs from where you loaded the bundle.

For funnel steps and CTA clicks, prefer the markup attributes — no JS file, no wiring: data-mn-event="signup-cta-click" plus data-mn-subject-type="funnel" (and data-mn-once="session" for step milestones). For anything that isn't a click, window.mn.analytics.track(subjectId, attrs?) / window.mn.analytics.trackOnce(...) are exposed once the bundle loads, and window.mn.analytics.trackSearch(query, resultCount) reports your own site search so analytics_list_zero_result_searches has something to show — see skill://analytics/track-website-traffic. Drop-off across those steps is then one call to analytics_get_funnel.

2c. Identify logged-in users (optional, but it's what makes journeys/funnels pay off)

Anonymous tracking works with zero extra setup. But if the site has signed-in users, link each one to a known identity so their page-views attach to a CRM contact and funnels stop double-counting the anonymous → signed-in transition.

On the first authenticated page load, read the visitor id, have your server sign it together with your stable user id, then call identify. The tracker loads async — gate on the window.mn.analytics.ready flag / munin:analytics-ready document event rather than polling for window.mn.analytics:

const go = () => {
  const visitorId = window.mn.analytics.getVisitorId();
  // POST { externalId: 'user_42', visitorId, email } to your server; it signs
  //   ['mn.identity.v1', externalId, visitorId, email].map(f =>
  //     `${Buffer.byteLength(f, 'utf8')}:${f}`).join('')
  // with the tracker's identity secret and returns userHash.
  window.mn.analytics.identify('user_42', userHash, { email });
};

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

The hash binds the specific browser to the identity, so an observed hash can't be replayed to hijack a different visitor's trail — which is why it must be signed per session rather than baked into the script tag. Each field is length-prefixed so no value can be shifted across a field boundary.

Pass the email if the same people also reach you by email. It is optional, but it is the only thing that joins this browser to the identity the email channel created when they wrote in — without it, the person who emailed support last week and signs in today stays two separate records, and their journey starts at the login page. The address is inside the HMAC precisely so a browser can't claim someone else's; sign it server-side, never trust one the client supplies. Full recipe (minting the identity secret, how the two orderings resolve): skill://analytics/identify-visitors.

Step 3 — CMS content (the CORS trap)

This is where every coding agent gets stuck. The CMS delivery API is public, anonymous, and intentionally has no CORS headers. Browsers will block direct fetches from your frontend's origin. You have two correct options; do not try a third.

The endpoint

  • List entries: GET https://api.getmunin.com/v1/cms/{{ORG_ID}}/{collectionSlug}?locale=en&limit=20
  • Single entry by slug: GET https://api.getmunin.com/v1/cms/{{ORG_ID}}/{collectionSlug}/{entrySlug}?locale=en

Returns plain JSON: { collection, items: [...] } for the list, a flat entry object ({ slug, locale, data, version, publishedAt, updatedAt, ... }) for one entry. No auth header.

A localized site routes on the slug of the locale it is serving: each locale variant has its own slug, and the single-entry endpoint matches on slug and locale, so a pair with no published entry is a 404 rather than the default-language version. The single-entry response carries _locales: [{ locale, slug }, …] — every published variant of that entry — which is what you build hreflang tags and a language switcher from.

{{ORG_ID}} above is your tenant's org_… id, already substituted from your authenticated session — no need to ask for it. Store it in env too — MUNIN_ORG_ID / NEXT_PUBLIC_MUNIN_ORG_ID / VITE_MUNIN_ORG_ID per the framework convention above.

Option A — server-side fetch (recommended)

Do the fetch() from a server function / API route / loader / server component, never the browser. Bundle output is then plain HTML/JSON that the browser receives same-origin — no CORS check applies.

Framework-specific landing spots:

  • Next.js (app router)fetch() inside a server component or route handler.
  • Next.js (pages)getServerSideProps / getStaticProps / pages/api/*.
  • Remix / React Router — a route loader.
  • TanStack StartcreateServerFn.
  • SvelteKit+page.server.ts load.
  • Astro — top-level await fetch() in a .astro file.
  • Plain Vite/CRA SPA — add a tiny Node/Cloudflare-Worker proxy; there is no client-only escape.

ISR / edge caching is fine and recommended (revalidate on the order of 60s). The endpoint is cheap and serves stable JSON.

Option B — your own server-side proxy

If the frontend has no server runtime (static export, client-only SPA), stand up a one-line proxy endpoint on a Worker, Lambda, or Node server that forwards to the Munin delivery API and sets its own CORS headers. Same end result — the browser only ever talks to your origin.

Previewing drafts

The delivery API only serves published entries — except the single-entry route, which accepts ?preview=<token>: a signed, entry-scoped, 1-hour token minted with cms_get_preview_link. The standard wiring is a draft-mode route handler on the frontend (receive token → enable draft mode → redirect; the server-side fetch appends &preview= while draft mode is on) plus a previewUrl template on the collection so Munin can build the link. Full recipe: skill://cms/preview-entry.

What NOT to do

  • Don't try to add the frontend's origin to a CORS allowlist on the Munin side. There isn't one for the delivery API; this is by design. The endpoint is intended for server-to-server / CDN-edge consumers.
  • Don't paste the response into a posts.ts file and call it live. A frequent failure mode: the agent generates a static fallback, then claims content is "live from Munin." Either it's actually fetched at request/build time, or the integration is incomplete — say so.
  • Don't add mode: 'no-cors' to the fetch. It silently returns an opaque response your code can't read.

Step 4 — authoring content (optional)

If the operator also wants demo/seed content in the CMS so the frontend has something to render:

  1. cms_list_collections — find or create the relevant collection (e.g. blog-posts). If missing, cms_create_collection with the schema the frontend expects.
  2. cms_create_entry per article, then cms_publish_entry for each one.

Confirm with cms_list_entries({ collectionSlug, published: true }) before reporting done. Published entries appear in the delivery API within the cache TTL (~60s).

See skill://cms/publish-entry and skill://cms/migrate-content for the full authoring loop.

End-to-end smoke test

Before reporting the integration complete, in a real browser on the preview origin:

  1. Widget — the bubble renders, opens, and accepts a message. Confirm receipt from the admin side with conv_list_conversations({ limit: 5 }) — the test message should appear within ~2s.
  2. Tracker — load two pages, then analytics_list_top_subjects({ subjectType: "page", sinceDays: 1, limit: 10 }). Both subjectIds should be there (visits propagate within a few seconds).
  3. CMS — the rendered list/detail pages show entries fetched live (not a stale build-time snapshot). Publish one new entry via MCP; within the cache TTL it should appear after a refresh.

If any of the three fails, the most likely cause is in this table:

SymptomProbable cause
[munin-widget] data-munin-host: data-munin-host is required (or data-widget-key, data-channel-id)Missing or mis-named attribute on the <script> tag. They are all required; names are exact.
Widget loads but messages return 403originAllowlist doesn't include the exact origin shown in DevTools → Network → request headers → Origin. Update with conv_update_widget_channel.
Tracker loads, mn global exists, but no events appear in analytics_list_top_subjectsallowedOrigins mismatch — same fix as widget, via analytics_update_tracker.
Access to fetch ... has been blocked by CORS policy on /v1/cms/...You're calling the CMS delivery API from the browser. Move the fetch server-side per step 3.
404 on https://api.getmunin.com/embed/widget.js or https://api.getmunin.com/embed/tracker.jsOld path. Both bundles are served from the root: /widget.js and /tracker.js.
404 on /v1/cms/{orgId}/{slug}The collection slug or org id is wrong. cms_list_collections returns the canonical slugs.

What NOT to do

  • Don't guess API_URL or hardcode it in source. It's been substituted into this playbook and stated in the server instructions — use that value and write it into the frontend's env (NEXT_PUBLIC_API_URL / VITE_API_URL / etc.). Every deployment has its own host — never assume one, and never copy a host out of another project.
  • Don't put the widget key or tracker key in a .env as a server-only secret. Both are designed to be visible in browser source. The origin allowlist is what protects them.
  • Don't ship to production with an empty originAllowlist / allowedOrigins. With MUNIN_WIDGET_REQUIRE_ALLOWLIST / MUNIN_TRACKER_REQUIRE_ALLOWLIST unset (the default), empty means open-to-any-origin. Production deployments should both set the env var to 1 and configure the actual origins.
  • Don't skip the smoke test. All three integrations have silent-failure modes (widget renders but ingest 403s; tracker loads but allowedOrigins blocks events; CMS fetch returns build-time data). Verify each in a real browser before reporting done.
  • Don't mix this playbook with the embedded chat widget for end-user agents (skill://conv/setup-chat-widget step 4, "browser-direct integration"). That's a different threat model: this playbook is browser-embed; that one is agent-pushed transcripts.

Related

  • skill://conv/setup-chat-widget — full widget detail, identified users, server-to-server posting.
  • skill://analytics/track-website-traffic — full tracker detail, custom events, querying the data.
  • skill://analytics/track-cms-views — per-entry view tracking when serving CMS content yourself.
  • skill://cms/publish-entry — authoring side.
  • skill://cms/migrate-content — bulk content seeding.