Appearance
Slack agents
A Slack agent wakes when someone mentions it, sends it a DM, or posts in a channel it watches. From there, it keeps the conversation in one thread and can reply all at once, stream the answer live, or decide for itself when to post updates.
Run agent-sdk slack create --dir ., then agent-sdk slack doctor.
Answer a mention or DM
Mention the bot in a channel or send it a DM, and it will answer in a thread on that message. Any follow-up in the same thread continues the conversation where it left off.
ts
// bot/channels/slack.ts
import { slackChannel } from "@grok/agent-sdk/channels/slack";
export default slackChannel({
// TRIAGE_SLACK_BOT_TOKEN and TRIAGE_SLACK_APP_TOKEN
envPrefix: "TRIAGE",
});Post one reply with a status chip
By default, Slack shows a status chip beneath the thread while the turn runs, cycling through idle messages until reasoning or tool activity gives it something more specific to show. When the turn finishes, the chip disappears and one complete reply lands in the thread.
ts
export default slackChannel({
reply: {
mode: "post",
status: {
// First line of each reasoning block on the chip.
reasoning: true,
// Tool names as they start ("Running grep, glob...").
tools: true,
idle: ["Checking the request...", "Reviewing recent activity..."],
},
},
});Stream a live reply
In stream mode, the reply appears immediately and grows as the model writes. Slack shows its generic "is working..." state until the stream ends, while optional reasoning and tool cards keep progress inside the same message.
ts
export default slackChannel({
reply: {
mode: "stream",
// Thinking card inside the streamed message.
reasoningCard: true,
// One card per tool in that same message.
toolCards: true,
},
});Let the agent own every message
Use agentic mode when the agent should decide how to communicate, not only what to say. The bot reacts to the parent message with eyes and shows a thinking chip, then the agent chooses when to post progress and how to deliver the final reply.
slack-communication gives those posts a Slack-aware voice by default: lead with the result, share progress when the picture changes, and keep tool chatter off the thread. Use tone for the rest of the communication contract, including voice, level of detail, formatting, and how updates should be structured.
ts
// bot/extensions/slack.ts
import slackCommunication from "@grok/agent-sdk/extensions/slack-communication";
export default slackCommunication({
tone:
"Be concise but warm. Use bullets for evidence and end with the next action.",
});ts
export default slackChannel({
// `post-update` writes mid-turn progress and the final reply.
reply: { mode: "agentic" },
});Show steps as cards or as a checklist
The agent calls post-card to show one step of its work, such as "Scan the repo". Each card has an id, a title, a status, and optional details. When the agent calls post-card again with the same id, Slack updates that card. The agent uses this to move a step from in_progress to complete.
cards sets how Slack draws these steps:
cards | What Slack shows | Use it when |
|---|---|---|
{ display: "timeline" } (default) | Each step is a separate card in the thread. | The turn has one or two short steps. |
{ display: "plan" } | All steps from one turn are in one checklist. | The turn has several steps, and readers want to see progress in one place. |
This bot shows its steps as one checklist with the title "Release check":
ts
export default slackChannel({
reply: {
mode: "agentic",
cards: { display: "plan", title: "Release check" },
},
});Know these rules for a plan:
- The
titleis optional. It can have at most 256 characters. - You set the
titlein the channel configuration. The agent cannot change the title. - Each turn starts a new checklist with the same title.
- The
pendingstatus shows a step that has not started. Usependingonly in a plan. slackChannelstops at startup ifcardsis not valid. For example, atitlewithdisplay: "timeline"stops startup.
display is Slack's task_display_mode, and the channel sends title as a plan_update chunk.
Wake on a channel post
This example watches #triage-alerts, so any substantial top-level message starts a thread even when nobody mentions the bot. The length guard filters out short posts before they can start a turn.
Mentions work in any channel the bot has joined. Listening without a mention requires an explicit allowlist with at least one channel ID starting with C or G and at most 10 distinct IDs. You can add #name entries for readability. A lone "*" watches replies in every joined channel (posts: "replies"), but it does not wake on top-level posts. Do not mix "*" with channel IDs.
ts
export default slackChannel({
engagement: {
channelPosts: {
allow: ["C0123ABCDEF", "#triage-alerts"],
posts: "top-level",
debounceMs: 15_000,
},
},
onChannelPost: async (_ctx, message) => {
if (message.markdown.length <= 20) {
return null;
}
// `{}` starts a model turn as the sender.
return {};
},
});When you create the app, pass --channel-posts and invite the bot to every channel in the allowlist.
On Cursor-managed hosting, the channel IDs in allow define which top-level posts can wake the agent. Replies in threads the agent has already joined still arrive. debounceMs applies only when you self-host. Add the channels:read scope when you include #name labels.
Control who can message
By default, people outside your workspace get no reply. This matters most in Slack Connect channels, where external collaborators share the channel and can mention or reply to bots installed by your organization. Keeping blockExternals on prevents them from starting or steering an agent session and, through it, directing any tools available to the agent.
Set blockExternals: false only when external collaborators are intended users of the agent.
ts
export default slackChannel({
// Let external collaborators start and continue agent sessions.
policy: { blockExternals: false },
});Anyone in a thread can continue its session by default. If the conversation should stay between the agent and its original author, set respondTo: "author".
ts
export default slackChannel({
policy: { respondTo: "author" },
});Events
Mentions and DMs wake the agent automatically, while onChannelPost runs for messages in watched channels. Declare onAppMention or onDirectMessage when you need to filter those default wakes; each handler returns {} to start a turn or null to skip it.
For example, this handler only starts a turn for DMs prefixed with triage::
ts
onDirectMessage: async (_ctx, message) => {
if (!message.markdown.startsWith("triage:")) {
return null;
}
// `{}` starts a model turn as the sender.
return {};
},Thread affinity
Mention the bot in a channel and it answers in a thread on that message. Follow-ups in the same thread continue that session.
A direct message continues the one session for that DM, including replies under later top-level messages. To give each top-level DM its own session, and keep replies in that message's thread on it, set the continuation selector:
ts
export default slackChannel({
continuation: "slack.dmthread/v2",
});Under slack.dmthread/v2, a DM thread behaves like a channel thread: a reply loads the thread's history, the turn edits only its own placeholder, and an approval click must come from that thread.
Channel threads keep one session per thread with either selector. slack.none/v2 starts a new session on every delivery.
Call Slack from deterministic code via ctx
Channel hooks can post before starting the model turn, which is useful for deterministic acknowledgements such as Looking at this now. The same bot credential also lets tools call Slack later in the turn:
Channel hooks:
ctx.thread.post(),ctx.slack.channelId,ctx.slack.threadTsTools:
ctx.host.slack.getClient()
ts
onAppMention: async (ctx, message) => {
if (message.markdown.trim() === "") {
return null;
}
await ctx.thread.post("Looking at this now.");
return {};
},Use the IDs on the hook ctx when handling an event, and ctx.host.slack.getClient() when a tool needs the Slack Web API.
Connect
bash
agent-sdk login
agent-sdk slack create --dir .
agent-sdk slack doctor --prefix TRIAGEAfter you sign in, slack create opens Add Slack to this agent in the browser and writes the resulting tokens to .env.local without printing them. You can open the same wizard from Integrations on Deployed Agents.
If Slack requires workspace-admin approval, keep slack create running and open the Request approval link printed by the CLI. Installing from Deployed Agents doesn't send that request for you. After an admin approves it, return to the Add Slack wizard there and select Retry.
Choose the bot scopes
A Cursor-managed app requests these bot scopes by default:
app_mentions:read, assistant:write, channels:history, channels:read, chat:write, files:read, files:write, groups:history, groups:read, im:history, im:read, im:write, mpim:history, reactions:read, reactions:write, usergroups:read, users:read, users:read.email
To request a different list, set botScopes in slack-manifest.json at the root of the agent project. The app then requests exactly that list:
json
{
"botScopes": [
"app_mentions:read",
"assistant:write",
"channels:history",
"chat:write",
"groups:history",
"im:history",
"reactions:read",
"pins:read"
]
}Then run agent-sdk slack create again (add --env prod for the production app). The CLI shows the list before it opens the browser. Slack updates the app and installs it again. If the workspace requires admin approval, the approval request lists every scope the app requests.
- The list must include the required scopes:
app_mentions:read,assistant:write,channels:history,chat:write,groups:history,im:history, andreactions:read. The Slack channel needs them to receive mentions, DMs, channel posts, and reactions, and to reply. - If you remove a default scope, the features that use it stop working. For example, file uploads need
files:write. - Use bot scopes only, at most 50. User scopes are not supported.
- Cursor rejects scopes in the Slack Marketplace forbidden families:
admin.*,identity.*,search:read*,triggers:*,apps_configuration:*, andworkflow.steps:execute. - Without
botScopes, the app requests the default list plus any other scopes it already has. To remove a scope you added, list the scopes you want and runagent-sdk slack createagain.
The person who runs agent-sdk slack create also grants Cursor three user scopes in their own Slack account: app_configurations:read, app_configurations:write, and managed_apps:install. Cursor uses them to create, update, and install the agent's app. They are not scopes of the agent's app, and botScopes does not change them.
If you already own the Slack app, initialize it with agent-sdk slack init --manual --dir . --name "Triage" and follow the Slack setup skill for installation and tokens.
Test locally
Run doctor first to catch missing credentials, then start the agent and send it the same mention or DM a teammate would.
bash
agent-sdk slack doctor --prefix TRIAGE
agent-sdk serve --dir . --devPractices
If the tokens are missing, the Slack channel stays idle while the rest of
servestarts normally.Keep
channelPosts.allownarrow because each qualifying post can start a model turn.Set
envPrefixwhen two Slack bots share a host so the token names do not collide.
Related
- CLI reference:
slack create,doctor,init --manual skills/setup-slack/SKILL.md: the companion skill for coding agents- Webhooks and custom channels: host prep on mention and DM handlers
- Evals: freeze a mention as a regression check