Slack Integration
Build Slack-powered surfaces for workspaces. Mug creates per-client Slack apps via manifest API — each client gets their own branded app, one-click install, full Block Kit control.
For full API reference, see .mug/docs/slack.md, .mug/docs/api.md (Slack section), and .mug/docs/notifications.md.
slack.json — App Configuration
Every workspace has a slack.json at the root. Created by mug init with {"enabled": false}.
Minimal — enable Slack
{
"enabled": true,
"name": "Acme Dispatch",
"description": "Job dispatch and approval"
}
Full schema
{
"enabled": true,
"name": "Acme Dispatch",
"description": "Job dispatch and approval",
"color": "#1a1a2e",
"botName": "acme-ops",
"homeTab": {
"enabled": true,
"sections": [
{
"type": "text",
"title": "Operations Dashboard",
"text": "Real-time overview of active jobs and crew status."
},
{
"type": "query",
"title": "Active Jobs",
"database": "jobs",
"query": "SELECT title, status, assignee FROM jobs WHERE status = 'active' ORDER BY created_at DESC",
"columns": ["title", "status", "assignee"]
},
{
"type": "actions",
"buttons": [
{ "text": "Run Daily Report", "workflow": "daily-report", "style": "primary" },
{ "text": "Sync All Sources", "workflow": "run-sync" }
]
},
{ "type": "divider" },
{
"type": "query",
"title": "Recent Alerts",
"database": "ops",
"query": "SELECT message, created_at FROM alerts ORDER BY created_at DESC LIMIT 5",
"columns": ["message", "created_at"],
"emptyMessage": "No recent alerts."
}
]
},
"messagesTab": true,
"shortcuts": [
{
"name": "Create Dispatch",
"callbackId": "create_dispatch",
"description": "Create a new dispatch job",
"type": "global",
"workflow": "create-dispatch"
}
],
"unfurlDomains": ["acme.mug.work"],
"scopes": []
}
Field reference
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | false |
Enable Slack app for this workspace |
name |
string | workspace name | App display name in Slack (max 35 chars) |
description |
string | — | App description (max 140 chars) |
color |
string | — | App background color (hex, e.g. "#1a1a2e") |
botName |
string | same as name | Bot display name (max 80 chars) |
homeTab |
object | — | Home Tab configuration (see below) |
messagesTab |
boolean | false |
Enable DM tab — users can message the bot directly. Auto-enabled when any agent has chat: true in agent.json |
defaultAgent |
string | — | Agent name for DMs when no agent is specified (must match an agents/<name>/ folder with chat: true) |
suggestedPrompts |
array | auto-generated | Prompts shown when user opens a DM. Each: { title, agent } or { title, message }. Max 4. |
appIcon |
string | — | Path to app icon image (512-2000px square) for reference. Must be uploaded manually via Slack UI — the manifest API doesn't support icons. |
shortcuts |
array | — | Global and message shortcuts (see below) |
unfurlDomains |
string[] | — | Domains to unfurl with rich previews (max 5) |
scopes |
string[] | — | Additional OAuth scopes (most are auto-inferred) |
commands |
object | — | Explicit slash commands not tied to workflow triggers |
events |
string[] | — | Additional event subscriptions |
Scope auto-inference
Most scopes are inferred automatically:
- Always added:
chat:write,channels:join,channels:read,groups:read,users:read,users:read.email,im:write,mpim:read,mpim:write,reactions:write,files:write commands— when slash commands or shortcuts existim:history— when Messages Tab is enabledlinks:read,links:write— when unfurl domains are configuredassistant:write,app_mentions:read— when chat agents detected
Add explicit scopes only for capabilities not covered by auto-inference.
Scope change warnings
When a deploy adds new OAuth scopes, mug deploy warns:
⚠ New OAuth scopes: users:read, users:read.email — workspace admin may need to re-authorize
Home Tab
The Home Tab is a per-user dashboard inside the Slack app. Configured in slack.json as sections, rendered automatically when a user opens the app.
Section types
query — run SQL, render as a table:
{
"type": "query",
"title": "Active Projects",
"database": "projects",
"query": "SELECT name, status, manager FROM projects WHERE status = 'active'",
"columns": ["name", "status", "manager"],
"emptyMessage": "No active projects."
}
actions — buttons that trigger workflows:
{
"type": "actions",
"buttons": [
{ "text": "Run Daily Report", "workflow": "daily-report", "style": "primary" },
{ "text": "Sync Data", "workflow": "run-sync" }
]
}
Button styles: "primary" (green), "danger" (red), or omit for default gray.
text — headers and markdown:
{
"type": "text",
"title": "Welcome",
"text": "Operations dashboard for Acme. Updated in real-time."
}
divider — visual separator:
{ "type": "divider" }
Limits
- Max 100 blocks per Home Tab (Slack limit). Sections truncate with "showing X of Y" if over.
- Query results capped at 15 rows per section.
- Home Tab refreshes each time a user opens the app.
Shortcuts
Shortcuts appear in Slack's lightning bolt menu (global) or message context menu (message).
{
"shortcuts": [
{
"name": "Run Workflow",
"callbackId": "run_workflow",
"description": "Trigger a Mug workflow",
"type": "global",
"workflow": "run-workflow"
},
{
"name": "Summarize Thread",
"callbackId": "summarize",
"description": "AI summary of this thread",
"type": "message",
"workflow": "summarize-thread"
}
]
}
The workflow field maps the shortcut directly to a workflow. The workflow receives:
ctx.params.callbackId— the shortcut's callback IDctx.params.triggerId— for opening modalsctx.params.userId,ctx.params.userName- For message shortcuts:
ctx.params.messageTs,ctx.params.channelId,ctx.params.messageText
Workflow Triggers
Slack triggers are defined in workflow .ts files, not in slack.json:
import { workflow } from "@mugwork/mug";
workflow("handle-dispatch", async (ctx) => {
// ctx.params.command, ctx.params.text, ctx.params.triggerId
await ctx.slack.openModal({ ... });
}, {
trigger: { type: "slack_command", command: "/dispatch", description: "Create a dispatch" },
});
workflow("classify-message", async (ctx) => {
// ctx.params.text, ctx.params.userId, ctx.params.channelId
}, {
trigger: { type: "slack_event", event: "message" },
});
Triggers merge into the manifest automatically at deploy time.
Sending Messages
to accepts a channel name (#ops-alerts or ops-alerts) or a channel ID (C01234ABCDE). Channel names are resolved automatically. The bot auto-joins public channels on first message — private channels need the bot added manually via the channel's Integrations tab.
// Plain text
await ctx.notify.slack({
to: "#ops-alerts",
message: "New job assigned",
});
// Block Kit — raw blocks, no Mug abstraction
await ctx.notify.slack({
to: "C01234ABCDE",
message: "Approval needed",
blocks: [
{
type: "section",
text: { type: "mrkdwn", text: `*New job:* ${job.title}\n*Customer:* ${job.customer}` },
},
{
type: "actions",
elements: [
{
type: "button",
text: { type: "plain_text", text: "Approve" },
action_id: "mug:handle-approval:approve",
value: job.id.toString(),
style: "primary",
},
{
type: "button",
text: { type: "plain_text", text: "Reject" },
action_id: "mug:handle-approval:reject",
value: job.id.toString(),
style: "danger",
},
],
},
],
});
// Threading
await ctx.notify.slack({
to: channelId,
message: "Update on the job",
thread_ts: originalMessageTs,
});
Action ID Convention
Button action_id format: mug:<workflow>:<custom>
mug:handle-approval:approve→ routes tohandle-approvalworkflow- Without
mug:prefix → routes to the default inbound Slack handler
The workflow receives ctx.params.actionId (the custom part) and ctx.params.actionValue.
Message Updates
await ctx.slack.updateMessage({
channel: ctx.params.channelId,
ts: ctx.params.messageTs,
text: "Approved",
blocks: [
{ type: "section", text: { type: "mrkdwn", text: `*Approved* by <@${ctx.params.userId}>` } },
],
});
Slash Commands
workflow("handle-dispatch", async (ctx) => {
await ctx.slack.openModal({
triggerId: ctx.params.triggerId,
view: {
type: "modal",
title: { type: "plain_text", text: "Create Dispatch" },
submit: { type: "plain_text", text: "Create" },
blocks: [
{
type: "input",
element: { type: "plain_text_input", action_id: "title" },
label: { type: "plain_text", text: "Job Title" },
},
],
},
});
}, {
trigger: { type: "slack_command", command: "/dispatch", description: "Create a dispatch" },
});
Modal Forms
Slash commands and shortcuts can open modals for structured data collection. The same workflow handles opening, submission, and dynamic selects.
Open a modal
workflow("create-job", async (ctx) => {
if (ctx.params.type === "slash_command") {
await ctx.slack.openModal({
triggerId: ctx.params.triggerId,
view: {
type: "modal",
callback_id: "mug:create-job:submit",
private_metadata: JSON.stringify({ channelId: ctx.params.channelId }),
title: { type: "plain_text", text: "New Job" },
submit: { type: "plain_text", text: "Create" },
blocks: [
{
type: "input",
block_id: "title_block",
label: { type: "plain_text", text: "Title" },
element: { type: "plain_text_input", action_id: "title" },
},
{
type: "input",
block_id: "priority_block",
label: { type: "plain_text", text: "Priority" },
element: {
type: "static_select",
action_id: "priority",
options: [
{ text: { type: "plain_text", text: "Low" }, value: "low" },
{ text: { type: "plain_text", text: "Medium" }, value: "medium" },
{ text: { type: "plain_text", text: "High" }, value: "high" },
],
},
},
{
type: "input",
block_id: "customer_block",
label: { type: "plain_text", text: "Customer" },
element: {
type: "external_select",
action_id: "customer-picker",
placeholder: { type: "plain_text", text: "Search customers..." },
min_query_length: 1,
},
},
],
},
});
return { opened: true };
}
// Handle submission
if (ctx.params.type === "view_submission") {
const values = ctx.params.formValues;
const title = values?.title_block?.title?.value;
const priority = values?.priority_block?.priority?.selected_option?.value;
const customer = values?.customer_block?.["customer-picker"]?.selected_option?.text?.text;
const meta = JSON.parse(ctx.params.metadata || "{}");
await ctx.exec("INSERT INTO jobs (title, priority, customer) VALUES (?, ?, ?)", [title, priority, customer]);
await ctx.notify.slack({ to: meta.channelId, message: `Job created: ${title} (${priority}) for ${customer}` });
return { created: true };
}
}, {
trigger: { type: "slack_command", command: "/newjob", description: "Create a new job" },
});
Modal submission params
When a user submits a modal, the workflow receives:
ctx.params.type—"view_submission"ctx.params.actionId— the custom part fromcallback_id(e.g."submit"from"mug:create-job:submit")ctx.params.formValues— nested object:{ block_id: { action_id: { value, selected_option, ... } } }ctx.params.metadata— theprivate_metadatastring from the modal (stash context like channelId here)ctx.params.viewId— for updating the modal viactx.slack.updateModal()ctx.params.userId,ctx.params.userName,ctx.params.triggerId
Update a modal (multi-step flows)
await ctx.slack.updateModal({
viewId: ctx.params.viewId,
view: { type: "modal", title: { ... }, blocks: [ /* step 2 blocks */ ] },
});
Dynamic select menus
For dropdowns that search large datasets (100+ options), use external_select in the modal block and configure a suggestions mapping in slack.json:
{
"suggestions": {
"customer-picker": {
"database": "crm",
"query": "SELECT name, id FROM customers WHERE name LIKE ? AND _mug_deleted_at IS NULL LIMIT 20"
}
}
}
The action_id on the external_select block must match the key in suggestions. The query receives the user's typed text as a %value% LIKE parameter. First column = display text, second column = value.
For small option sets (under 100), use static_select instead — no config needed.
Human-in-the-Loop
const callbackUrl = await ctx.waitForUrl("approval");
await ctx.notify.slack({
to: "#approvals",
message: "Approve this job?",
blocks: [
{ type: "section", text: { type: "mrkdwn", text: `*${job.title}* — $${job.amount}` } },
{
type: "actions",
elements: [
{
type: "button",
text: { type: "plain_text", text: "Approve" },
url: `${callbackUrl}?action=approved`,
style: "primary",
},
{
type: "button",
text: { type: "plain_text", text: "Reject" },
url: `${callbackUrl}?action=rejected`,
style: "danger",
},
],
},
],
});
const result = await ctx.waitFor("approval", { timeout: "24h" });
if (result.timedOut) { /* escalate */ }
// result.payload.action === "approved" or "rejected"
Slack Data
After Slack app install, slack_users and slack_channels tables auto-sync every 6 hours.
SELECT t.name, t.specialty, s.display_name, s.email
FROM technicians t
JOIN slack_users s ON s.email = t.email;
App Setup
mug slack setup supports both interactive (terminal) and flag-driven (agent) modes. All flags can be used by AI agents — no readline prompts needed.
Check current state
mug slack setup --json
Returns machine-readable JSON with all state fields: appId, hasCredentials, hasConfigToken, configTokenValid, hasBotToken, hasAgents, installUrl, configTokenUrl, plus app-specific URLs (eventSubscriptionsUrl, agentToggleUrl, appSettingsUrl) when an app exists.
Always start here to determine which flow to run.
Flow 1 — New app setup (developer is admin)
- Check state:
mug slack setup --json - Open the config token page for the user: run
open https://api.slack.com/apps(or use theconfigTokenUrlfrom--json) - Tell the user: "Scroll to 'App Configuration Tokens' at the bottom, click 'Generate Token', select your workspace, copy the Access Token and Refresh Token"
- User pastes both tokens back — save them:
mug slack setup --config-token "xoxe.xoxp-..." --refresh-token "xoxe-..." - Create the app:
mug slack setup --create-app - Open the install URL for the user:
mug slack setup --install-url --open - After install, check state again with
--jsonto confirmhasBotToken: true - Open the app settings page so the user can upload a custom app icon (512px+ square PNG):
Theopen <appSettingsUrl from --json>appSettingsUrlfield from--jsonresolves tohttps://api.slack.com/apps/<APP_ID>/general.
Flow 2 — New app setup (developer is NOT admin)
- Generate instructions for the Slack admin:
This outputs copy-pasteable plain text with numbered steps and the manifest JSON included. The user sends this to their Slack admin.mug slack setup --admin-instructions - Admin follows the instructions, sends back 6 values: App ID, Client ID, Client Secret, Signing Secret, Access Token, Refresh Token
- Save credentials:
mug slack setup --app-id "A0..." --client-id "..." --client-secret "..." --signing-secret "..." - Save config tokens:
mug slack setup --config-token "xoxe.xoxp-..." --refresh-token "xoxe-..." - Open install URL:
mug slack setup --install-url --open - After install, open the app settings page for custom icon upload:
open <appSettingsUrl from --json>
Flow 3 — Refresh expired config token
- Check state:
mug slack setup --json— look forconfigTokenValid: false - Open the config token page: run
open https://api.slack.com/apps - Tell the user: "Scroll to 'App Configuration Tokens', click 'Generate Token' for your workspace, copy both tokens"
- Save fresh tokens:
mug slack setup --config-token "xoxe.xoxp-..." --refresh-token "xoxe-..."
Flow 4 — Reinstall app (after scope changes)
- Open the install URL:
mug slack setup --install-url --open - User clicks "Allow" in the browser to re-authorize with new scopes
Other flags
mug slack setup --manifest # output generated manifest JSON
mug slack setup --install-url # print the OAuth install URL
mug slack setup --install-url --open # print and open in browser
After setup
- Deploy:
mug deploypushes the manifest and credentials - Install: someone with workspace admin access visits the install URL — OAuth stores the bot token automatically
- Future deploys: manifest auto-updates via config token, no manual steps
Secrets (managed by mug slack setup)
| Secret | Source |
|---|---|
SLACK_CONFIG_TOKEN |
Generated by workspace admin at api.slack.com |
SLACK_CONFIG_REFRESH_TOKEN |
Generated alongside config token |
SLACK_APP_ID |
Auto-stored on app creation (or from Basic Information page) |
SLACK_CLIENT_ID |
Auto-stored on app creation (or from Basic Information page) |
SLACK_CLIENT_SECRET |
Auto-stored on app creation (or from Basic Information page) |
SLACK_SIGNING_SECRET |
Auto-stored on app creation (or from Basic Information page) |
SLACK_BOT_TOKEN |
Auto-stored after OAuth install callback |
AI Agent DMs (Agents & AI Apps)
Mug uses Slack's "Agents & AI Apps" feature for agent conversations. When any agent has chat: true in agent.json, deploy auto-enables the Agents & AI mode with proper scopes and events.
Setup
- Add
"chat": truetoagents/<name>/agent.json - Set
"defaultAgent"inslack.jsonto the agent name mug deploy— auto-addsassistant:write,im:history,app_mentions:readscopes- Enable "Agents & AI Apps" in Slack UI:
https://api.slack.com/apps/<app_id>/app-assistant - Set suggested prompts to "Dynamic" in the same UI page
Multi-agent routing
Multiple agents can share one Slack app. Users are routed by:
- Suggested prompts: clicking a prompt pins the thread to that agent
- @mentions:
@BotName agent-name queryroutes to the named agent - Thread pinning: once a thread is assigned to an agent, all messages in that thread go to the same agent
Configure per-agent names visible in Slack with "slackName" in agent.json:
{
"model": "openai/gpt-4.1-nano",
"tools": ["query"],
"chat": true,
"slackName": "ops-bot"
}
Suggested prompts
Auto-generated from chat agents, or configure explicitly in slack.json:
{
"suggestedPrompts": [
{ "title": "Ask Ops Bot", "agent": "ops-bot" },
{ "title": "Check Schedule", "message": "What's on the schedule today?" }
]
}
agentprompts include anagent:<name>prefix to pin the threadmessageprompts send the text directly to the default agent- Max 4 prompts (Slack limit)
App icon
Slack's manifest API doesn't support setting app icons programmatically. Upload manually at https://api.slack.com/apps/<app_id>/general (512px+ square PNG). mug deploy prints this link on first app creation.
Block Kit Reference
Agents already know Block Kit. Use Slack's Block Kit Builder for visual design: https://app.slack.com/block-kit-builder
For Slack API docs: https://docs.slack.dev/apis/web-api.md