Building Bots
Building Slack Bots
A bot listens, decides, and responds. Where workflows are linear and user-assembled, a bot is conversational, stateful, and lives wherever your users do — in channels, in DMs, in threads.
A Slack bot is a Slack App with two halves wired together:
- It receives events — messages, mentions, button clicks, slash commands — pushed to your server by Slack's Events API.
- It calls the Web API — to post replies, open modals, update messages, or take action in other systems.
This guide covers how those two halves fit together and the patterns that show up in almost every production bot.
What Bots Are Good At
Conversational interfaces
Answer questions, run searches, hand off to a human — all without the user leaving Slack.
Slash commands
/deploy staging, /oncall page, /lookup customer 4821. Quick verbs that fit the channel.
Reactive automation
React to emoji, watch for keywords, or open a ticket when someone posts in a triage channel.
Interactive surfaces
Buttons, dropdowns, modals, and Home tabs that turn a chat surface into a real UI.
The Bot Anatomy
Follow Get API Credentials to create the app and grab a bot token. Bots need scopes like chat:write, app_mentions:read, commands, and im:history depending on what they do.
Pattern 1 — Respond to a Mention
The classic starter bot: someone @-mentions the app, the bot replies in-thread.
import express from 'express';
import { Client } from 'slack-apimatic-sdk';
const app = express();
app.use(express.json());
const slack = new Client({ token: process.env.SLACK_BOT_TOKEN! });
app.post('/slack/events', async (req, res) => {
// URL verification handshake (first-time only)
if (req.body.type === 'url_verification') {
return res.json({ challenge: req.body.challenge });
}
const event = req.body.event;
if (event?.type === 'app_mention') {
await slack.chatController.postMessage({
channel: event.channel,
thread_ts: event.ts,
text: `Hey <@${event.user}> — what can I help with?`,
});
}
res.sendStatus(200);
});
app.listen(3000);A few things to notice:
- The
url_verificationhandshake runs once when you first add the events endpoint. Echo back thechallengeso Slack marks the URL verified. - Always respond with 2xx in under 3 seconds. Do real work after sending the response — Slack retries delivery if you're slow, and your bot will fire twice.
- Thread your replies. Setting
thread_tskeeps channels readable.
Pattern 2 — Slash Commands
Slash commands are the fastest way to give users a verb. Register /deploy in your app's Slash Commands settings and point it at your server.
import express from 'express';
const app = express();
app.use(express.urlencoded({ extended: true }));
app.post('/slack/commands/deploy', async (req, res) => {
const userId = req.body.user_id as string;
const target = ((req.body.text as string) || '').trim() || 'staging';
// Ack within 3 seconds — do the deploy asynchronously.
enqueueDeploy({ user: userId, target });
res.json({
response_type: 'in_channel',
text: `:rocket: Deploying to *${target}* — kicked off by <@${userId}>.`,
});
});response_type: 'in_channel'makes the response visible to everyone (default isephemeral, only visible to the invoker).- Long-running work belongs in a queue. The HTTP response is your acknowledgement; post follow-ups with
chat.postMessageor by hitting theresponse_urlSlack provides.
Pattern 3 — Interactive Buttons and Modals
Add buttons to a message and Slack POSTs you a payload when the user clicks.
// Step 1: Post a message with a button
await slack.chatController.postMessage({
channel: '#approvals',
blocks: [
{
type: 'section',
text: { type: 'mrkdwn', text: '*New expense:* $1,240 — office equipment' },
},
{
type: 'actions',
elements: [
{ type: 'button', action_id: 'approve', text: { type: 'plain_text', text: 'Approve' }, style: 'primary', value: 'exp_4821' },
{ type: 'button', action_id: 'reject', text: { type: 'plain_text', text: 'Reject' }, style: 'danger', value: 'exp_4821' },
],
},
],
});
// Step 2: Handle the click
app.post('/slack/interactions', async (req, res) => {
const payload = JSON.parse(req.body.payload);
const action = payload.actions[0];
if (action.action_id === 'approve') {
await approveExpense(action.value, payload.user.id);
await slack.chatController.update({
channel: payload.channel.id,
ts: payload.message.ts,
text: `:white_check_mark: Approved by <@${payload.user.id}>`,
});
}
res.sendStatus(200);
});For multi-field input — collecting a reason, a date, a dropdown choice — open a modal with views.open instead of cramming everything into a message.
Pattern 4 — The App Home Tab
The Home tab is a personal view of your app that each user sees when they click your bot's name. It's the right place for dashboards, settings, and "what should I do next" surfaces.
app.post('/slack/events', async (req, res) => {
const event = req.body.event;
if (event?.type === 'app_home_opened' && event.tab === 'home') {
await slack.viewsController.publish({
user_id: event.user,
view: {
type: 'home',
blocks: [
{ type: 'header', text: { type: 'plain_text', text: 'Your On-Call Today' } },
{ type: 'section', text: { type: 'mrkdwn', text: '*Primary:* <@U123>\n*Secondary:* <@U456>' } },
{ type: 'actions', elements: [
{ type: 'button', action_id: 'swap', text: { type: 'plain_text', text: 'Swap with someone' } },
]},
],
},
});
}
res.sendStatus(200);
});Production Checklist
Bots vs. Workflows vs. Webhooks
| You need... | Build a... |
|---|---|
| Linear automation users assemble themselves | Workflow |
| One-way "post when X happens" alert | Incoming Webhook |
| Stateful conversation, buttons, modals, branching logic | Bot (this guide) |
| Native step inside someone else's workflow | Custom function |
Next Steps
Recipe: Onboarding Bot
A complete walkthrough of a bot that greets new workspace members and walks them through setup.
Recipe: Direct Messages
Open a DM channel and send a private message — the foundation of any 1:1 bot.
Recipe: Threaded Replies
Post a parent message and reply in its thread.
API Reference
Every method a bot can call — chat.postMessage, views.open, conversations.open, and more.

