Munin
Munin developer portal
Get a key →

Connect Slack for human handoff

Use this when the operator wants their team to triage Munin conversations from Slack. Every conversation becomes one Slack thread in a channel you pick: customer messages, AI replies, status changes, and takeover/assign updates post into the thread, and handover requests raise a prominent alert. Operators reply to the customer by replying in the thread. Slack is an operator surface — replies travel to the customer over the conversation's original channel (email, widget, SMS, voice).

TL;DR

  1. Confirm the deployment has a Slack app (slack_get_statusappConfigured). If it doesn't, create the app from the manifest below and set the env vars.
  2. Call slack_get_install_url and have the operator open and approve the link in a browser (workspace admin required).
  3. Call slack_set_routing with the channel ID conversations should mirror into; have the operator /invite the bot to that channel.
  4. Optionally route escalations to a second channel: slack_set_routing with purpose: "escalations" and a mention like <!here>; approvals and CMS publish announcements take their own channels the same way (purpose: "approvals" / "content").
  5. Verify with slack_send_test_message, then confirm slack_get_status shows connected: true.

Step 0 — deployment prerequisites

Check slack_get_status first: if appConfigured is true, the deployment already has a Slack app and you skip this whole step. If it is false, the operator must create one once for the deployment:

  1. Go to https://api.slack.com/appsCreate New AppFrom an app manifest and paste the manifest below — the URLs already carry this deployment's public API base URL (an unsubstituted slack-app-manifest.json also lives in the repo root):
{
  "display_information": {
    "name": "Munin",
    "description": "Mirrors Munin conversations into Slack for triage and handover alerts."
  },
  "features": {
    "bot_user": { "display_name": "Munin", "always_online": true }
  },
  "oauth_config": {
    "redirect_urls": ["https://api.getmunin.com/v1/slack/oauth/callback"],
    "scopes": {
      "bot": ["chat:write", "chat:write.customize", "channels:read", "channels:history", "users:read", "users:read.email"]
    }
  },
  "settings": {
    "event_subscriptions": {
      "request_url": "https://api.getmunin.com/v1/slack/events",
      "bot_events": ["message.channels", "member_joined_channel"]
    },
    "interactivity": {
      "is_enabled": true,
      "request_url": "https://api.getmunin.com/v1/slack/interactivity"
    },
    "org_deploy_enabled": false,
    "socket_mode_enabled": false,
    "token_rotation_enabled": false
  }
}
  1. From the app's Basic Information page, set these env vars on the backend and restart it:
    • SLACK_CLIENT_ID
    • SLACK_CLIENT_SECRET
    • SLACK_SIGNING_SECRET (signs the Events API requests that power reply-from-Slack)

The redirect URL must exactly match https://<api-base>/v1/slack/oauth/callback, and the events request URL https://<api-base>/v1/slack/events — where <api-base> is the backend's auth origin (NEXT_PUBLIC_AUTH_URL, or the origin of NEXT_PUBLIC_MCP_URL when unset). Slack verifies the events URL with a challenge when you save it; the backend must be reachable and have SLACK_SIGNING_SECRET set first.

Workspaces installed before the channels:history scope was added must reinstall via a fresh slack_get_install_url link before thread replies reach Munin. Likewise, workspaces installed before chat:write.customize was added show mirrored messages with a text author label instead of per-speaker names/icons until reinstalled.

Step 1 — install into the workspace

Call slack_get_install_url and give the operator the returned URL. They open it in a browser, pick the Slack workspace, and approve. The link expires after 10 minutes — mint a fresh one if they were slow. On success the browser lands back on the dashboard's Integrations page with slack=connected.

One workspace can serve multiple Munin orgs, but each Slack channel belongs to exactly one org.

Step 2 — route a channel

Conversations do not mirror until a default channel is routed. Two paths:

From Slack (simplest): the operator runs /invite @Munin in the channel they want. The bot posts a prompt with buttons (Mirror all conversations / Escalation alerts only / Not now); an org owner or admin clicks one and routing is set — no channel ID needed. The prompt is skipped when the channel is already routed or when several Munin orgs share the workspace.

From here:

  1. Call slack_list_channels and ask the operator which channel to use (public channels only — for a private channel, ask for its ID: channel details → About → Channel ID, e.g. C0123456789).
  2. Call slack_set_routing with { "slackChannelId": "C0123456789" }.
  3. If the response has botInChannel: false, the operator must run /invite @Munin in that channel — the bot cannot post until invited.

Optional escalations channel — handover alerts land here instead of the default channel, with an attention mention:

{ "slackChannelId": "C0456...", "purpose": "escalations", "mention": "<!here>" }

mention accepts Slack mention syntax: <!here>, <!channel>, or a user group like <!subteam^S0123456789>.

Optional source-channel routing — mirror conversations from one Munin conversation channel (find IDs with conv_list_channels) into their own Slack channel, e.g. widget chats to #support-chat while email keeps the default:

{ "slackChannelId": "C0789...", "convChannelId": "cch_..." }

Every route needs its own Slack channel (an escalations route pointing at the default channel is redundant — just leave it unset).

Step 3 — verify

Call slack_send_test_message — it posts a hello message to the default channel. Then confirm slack_get_status shows connected: true and the routes you expect. From now on, new conversation activity appears within a few seconds (the mirror worker polls its queue every 5 seconds).

What mirrors

  • New conversation → thread parent with contact, source channel, a dashboard link, a live status line (status, taken-over-by, assigned-to, needs-attention), and Take over / Close buttons (Release while taken over, Reopen once closed). The headline starts as "New conversation #N" and switches to the subject once one is set (the AI titles new conversations shortly after the first message). The buttons act as the clicking teammate — same account-linking rule as replies.
  • Customer messages (:bust_in_silhouette:), AI agent replies (:robot_face:), teammate replies (:technologist:), and internal notes (:lock:) as thread replies.
  • Status changes, assignment, claim/release, and handover request/resolve as thread updates. When a conversation is closed, the parent's status line becomes a ":white_check_mark: Conversation is resolved." banner (":no_entry_sign: Marked as spam." for spam) until it is reopened.
  • Handover requests additionally alert the escalations channel (or the default channel) with the reason and the configured mention.

Approval notifications

Items waiting on a human decision post with approve/dismiss buttons, and the message updates in place once the item is decided — from Slack, the dashboard, or an agent:

  • CRM merge proposals — standalone messages: duplicate contacts with the recommended keeper and confidence. Apply merge / Dismiss.
  • Outreach drafts — grouped per campaign: one parent message with a live pending count, each draft a thread reply with campaign, recipient, subject, and the full draft body quoted inline (Slack collapses long bodies behind its own Show more). Approve & send (this sends the real email) / Dismiss. A draft too long for one Slack message is cut with a (truncated) marker pointing at the dashboard — read and edit it there. Draft edits refresh the reply. When every draft in the wave is decided the parent flips to an all-handled banner. Parents last one day (UTC): the first draft of a new day posts a fresh parent (the campaign-wide pending count moves with it), and a previous parent that still showed pending drafts is rewritten to a "continued in a newer thread" notice — its still-pending replies stay actionable.
  • KB curation candidates — standalone messages: drafted knowledge-base articles awaiting review. Publish to <space> when the draft proposes a target space, otherwise only Dismiss (deletes the draft) plus a dashboard link for picking a space.

Routing: they land in the approvals channel when routed (slack_set_routing with purpose: "approvals"), otherwise fall back to the escalations channel, then the default channel:

{ "slackChannelId": "C0999...", "purpose": "approvals" }

Buttons act as the clicking teammate — the same account-linking rule as replies applies, and any linked org member can decide (matching the dashboard). Once resolved, the buttons disappear and the message shows the outcome and who decided.

Content announcements

Publishing a CMS entry posts a one-line announcement — ":rocket: Published<title>", the collection and locale, and a link to the live article. Scheduled publishes announce when the worker promotes them; re-publishing an already-published entry does not announce again. No buttons — it is news, not a decision.

Routing: the content channel when routed, otherwise the default channel.

{ "slackChannelId": "C0111...", "purpose": "content" }

The article link needs a live URL template on the collection (settings.liveUrl, e.g. https://www.example.com/{locale}/blog/{slug}) — without one the announcement still posts, just without a link. See skill://cms/publish-entry.

Translations of one article group into a thread: the locale that publishes first gets the channel message, and every other locale of the same translation group posts as a reply under it, so Slack shows one line with a reply count instead of four side-by-side headlines. Grouping is per UTC day — a locale published the next day starts its own channel message rather than reviving yesterday's thread. An entry with no siblings is a single message, exactly as if the collection were not localized.

Replying from Slack

A reply in a mirrored thread is sent to the customer over the conversation's original channel and recorded in Munin as that teammate's message. Replying does not take over the conversation — the AI stays in the loop; use the Take over button on the thread parent to take control (and Release to hand back):

  • Attribution is by email match: the Slack profile email must belong to a member of the Munin org. The first reply creates the mapping; later replies use it. When emails differ, link manually: slack_link_user with the Slack member ID (profile → Copy member ID) and the Munin user ID; inspect with slack_list_user_links, revoke with slack_unlink_user.
  • Unmapped users are rejected — the reply is not sent, and only the sender sees an ephemeral notice in the thread. Fix by inviting them to the org with their Slack email, or link them manually with slack_link_user.
  • Internal notes: start the reply with ! to keep it team-only (!checking with billing) — recorded as an internal note, never sent to the customer.
  • Assign from the thread: !assign @teammate assigns the conversation to that (linked) teammate; !assign me assigns yourself. The assignment mirrors back into the thread and the parent status line.
  • Attachments are not forwarded — Slack files live on Slack's authenticated CDN, so a file-only reply is rejected and a reply with files goes out as text only; the sender is told either way. Send files from the dashboard instead.
  • Only thread replies count; top-level channel messages, edits, and other bots are ignored. Attachment links on mirrored Munin messages appear as :paperclip: lines in the thread.

Troubleshooting

  • slack_not_configured — deployment env vars missing (step 0).
  • slack_bot_not_in_channel / parent messages missing — the bot was never invited to the routed channel.
  • slack_conflict on routing — that channel already mirrors a different Munin org; pick another channel.
  • Mirroring stopped after workspace changes — reinstall via slack_get_install_url (token may have been revoked), then re-check slack_get_status.
  • Delivery backlog — slack_get_status reports deliveries.pending and deliveries.failedLastDay; failures retry up to 5 times with backoff and respect Slack rate limits.