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.dev.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.dev.getmunin.com/v1/slack/events",
      "bot_events": ["message.channels", "member_joined_channel"]
    },
    "interactivity": {
      "is_enabled": true,
      "request_url": "https://api.dev.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 link that opens that conversation in Munin, 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.

Deleting a mirrored thread in Slack is allowed and needs no Munin-side cleanup: the deletion event drops the thread link, and an update that reaches a thread already gone posts nothing rather than falling back to the channel. The conversation starts a brand-new thread parent the next time something happens in it, so deleting a thread clears the mirror without stopping it.

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 link to the candidate for picking a space.

  • CMS drafts — standalone messages: an entry waiting to go live, with its title, collection, locale, slug and word count. Publish (this makes it live) / Dismiss (archives it). The card carries no body: an entry is blocks, assets and locales, so Slack shows what the decision is about and the dashboard shows what you are publishing. Editing a draft rewrites the message and rebinds the button to the new version, so a card left open on an older version is refused with a version conflict rather than publishing something else. The locales of one article thread under a parent — ":page_facing_up: Agentic support — 3 locales awaiting review" with the locales still pending listed on it — the same grouping the publish announcement uses, for the same reason. The parent closes once every locale is decided. An entry with no translations posts as a single card with no parent.

    Locales are usually written one call at a time, so the first one is an article of one when its card posts and lands in the channel with no parent above it. When the second locale arrives and the parent appears, that first card is moved into the thread — reposted under the parent and deleted where it stood — so the thread holds the whole set rather than one stray headline beside it. The card keeps its identity across the move: the same entry, the same buttons, still bound to the version it was rendered from. Publishing resolves the card in place, with the live article link on it. The separate content announcement is not repeated into the same channel — it posts only where the news would reach an audience the card did not, i.e. when a content channel is routed away from the card's channel.

  • Social post drafts — the platform, the variant label, the suggested author, the body quoted inline, the tracked share link, and a character count against the platform limit. Publish to <platform> / Dismiss. Publishing posts immediately and irreversibly from the clicking teammate's own connected account — never the suggested author's, which is why the card says so on its face. A teammate who has connected no account for that platform gets an ephemeral pointing at Settings → Integrations and the draft stays pending. Revising a draft rewrites the message and rebinds the button, so a card left open on an older wording refuses rather than posting text nobody approved. If the platform itself refuses the post the card closes with that outcome — the draft is terminally failed and a retry means a fresh draft.

    A set of variants is one post written several ways, so its cards thread under a parent: ":memo: LinkedIn post — 3 variants awaiting review". Publishing one variant dismisses the rest of its set — you are choosing a wording, not queueing three posts — so the parent names the variant that went out, who it went out as, and says the others were dismissed. Who that is depends on the platform: a LinkedIn card publishes under the clicker's own name, a Facebook card as the organisation's page, and each card says which before anyone clicks. The sibling cards resolve as dismissed at the same time. The parent closes once every variant is decided, and a one-off share is a set of one and posts as a single card with no parent.

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

Where the links go

Every card links at the thing it is about, not at the dashboard home: a conversation card opens that conversation, an approval card opens that item in the review queue, and a grouped parent (outreach campaign, CMS locale set, social variant set) opens the review queue, because the group is the several items under it.

On a deployment that hosts several organizations the link also names the one it belongs to — /o/<orgId>/dashboard/review/<id> — because one Slack workspace may serve several Munin orgs, and a teammate who belongs to more than one would otherwise land in whichever org their browser had open and find nothing. Following such a link to an org you are not a member of says so plainly instead of silently showing you a different one. A single-organization deployment has nothing to disambiguate, so its links stay plain /dashboard/review/<id>. Either way, following one while signed out sends you through sign-in and back to the item.

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.

For most kinds "acting as the teammate" is only attribution. For a social post draft it is the substance of the action: the post goes out under that person's name, from the grant Munin holds for them, so who clicks Publish decides whose feed it appears in.

Content announcements

A draft entry waiting for review raises an approval card (above); this section is about telling the wider team what went live. 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" }

Route a content channel and the two messages have two audiences: the people who decide see the card resolve where they decided it, and everyone else sees the news where they read it. Leave it unrouted and the announcement would land on top of its own approval card, so it is suppressed — the resolved card carries the live link instead. A publish that never had a card still announces either way: an entry created straight to published, one promoted by the scheduled-publish worker, or one an agent published outright.

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.
  • Slack formatting is translated to Markdown — Slack delivers a reply as mrkdwn, not as what you see in the composer: an emoji arrives as :slightly_smiling_face:, bold as *bold*, a link as <url|label>, a mention as <@U024BE7LH>. All of that is converted on the way in, so the customer reads 🙂, bold, a named link, and @Teammate rather than the raw source. Custom workspace emoji (:acme-logo:) have no Unicode equivalent and stay as their shortcode — avoid them in customer-facing replies.
  • 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.