Build for SlackBuilder's Special

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:

  1. It receives events — messages, mentions, button clicks, slash commands — pushed to your server by Slack's Events API.
  2. 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_verification handshake runs once when you first add the events endpoint. Echo back the challenge so 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_ts keeps 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 is ephemeral, only visible to the invoker).
  • Long-running work belongs in a queue. The HTTP response is your acknowledgement; post follow-ups with chat.postMessage or by hitting the response_url Slack 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 themselvesWorkflow
One-way "post when X happens" alertIncoming Webhook
Stateful conversation, buttons, modals, branching logicBot (this guide)
Native step inside someone else's workflowCustom function

Next Steps

On this page