Skip to main content

How to Build a Slack Bot from Scratch 2026

·APIScout Team
Share:

How to Build a Slack Bot from Scratch

Slack bots automate workflows, answer questions, and connect tools. This guide uses Bolt (Slack's official framework) to build a bot that handles slash commands, responds to messages, shows interactive modals, and runs on your server.

Internal Slack bots are one of the highest-ROI automation investments for engineering teams. A bot that posts deploy notifications, handles on-call escalations, or fields common support questions can save hours of manual work per week with a few days of implementation. The Slack API's richness — Block Kit UI, modals, shortcuts, workflows — means you can build genuinely polished tools without a frontend, and your team is already in Slack all day.

The two common bot architectures are internal bots (just your workspace) and distributed apps (other companies install your app). Internal bots are simpler: one bot token, no OAuth, no multi-tenancy. Distributed apps require OAuth, per-workspace token storage, and subscription management. This guide covers the internal bot pattern. The distributed app pattern adds about 2-3 weeks of implementation work but enables Slack App Directory distribution and monetization through your own subscription system.

What You'll Build

  • Slash command handler (/weather, /status)
  • Message listener (responds to keywords)
  • Interactive buttons and modals
  • Scheduled messages
  • App Home tab with dashboard

Prerequisites: Node.js 18+, Slack workspace where you can install apps.

1. Setup

Create a Slack App

  1. Go to api.slack.com/apps
  2. Click "Create New App" → "From scratch"
  3. Name it and select your workspace
  4. Under OAuth & Permissions, add these bot token scopes:
    • chat:write — send messages
    • commands — handle slash commands
    • app_mentions:read — respond to @mentions
    • im:history — direct messages
  5. Install the app to your workspace
  6. Copy the Bot Token (xoxb-...) and Signing Secret

Install Bolt

npm install @slack/bolt

Initialize

// app.ts
import { App } from '@slack/bolt';

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  socketMode: true, // For development (no public URL needed)
  appToken: process.env.SLACK_APP_TOKEN, // Required for socket mode
});

(async () => {
  await app.start(3000);
  console.log('⚡️ Slack bot is running!');
})();

Environment Variables

SLACK_BOT_TOKEN=xoxb-...
SLACK_SIGNING_SECRET=your_signing_secret
SLACK_APP_TOKEN=xapp-...  # For Socket Mode

2. Slash Commands

Register Command

In your Slack App settings → Slash Commands → Create New Command:

  • Command: /status
  • Request URL: https://your-server.com/slack/events (or use Socket Mode)

Handle Command

app.command('/status', async ({ command, ack, respond }) => {
  await ack(); // Acknowledge within 3 seconds

  // Do your work (fetch data, check systems, etc.)
  const status = await checkSystemStatus();

  await respond({
    blocks: [
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `*System Status* :white_check_mark:\n• API: ${status.api}\n• Database: ${status.db}\n• Cache: ${status.cache}`,
        },
      },
      {
        type: 'context',
        elements: [
          {
            type: 'mrkdwn',
            text: `Last checked: ${new Date().toLocaleTimeString()}`,
          },
        ],
      },
    ],
  });
});

3. Message Listeners

Respond to Keywords

// Listen for messages containing "help"
app.message(/help/i, async ({ message, say }) => {
  await say({
    blocks: [
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: 'Here\'s what I can do:',
        },
      },
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: '• `/status` — Check system status\n• `/deploy` — Trigger deployment\n• Just mention me with a question!',
        },
      },
    ],
  });
});

// Respond to @mentions
app.event('app_mention', async ({ event, say }) => {
  await say({
    text: `Hey <@${event.user}>! How can I help?`,
    thread_ts: event.ts, // Reply in thread
  });
});

4. Interactive Messages

Buttons

// Send a message with buttons
app.command('/deploy', async ({ command, ack, respond }) => {
  await ack();

  await respond({
    blocks: [
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `Deploy *${command.text || 'main'}* to production?`,
        },
      },
      {
        type: 'actions',
        elements: [
          {
            type: 'button',
            text: { type: 'plain_text', text: '✅ Deploy' },
            style: 'primary',
            action_id: 'deploy_confirm',
            value: command.text || 'main',
          },
          {
            type: 'button',
            text: { type: 'plain_text', text: '❌ Cancel' },
            style: 'danger',
            action_id: 'deploy_cancel',
          },
        ],
      },
    ],
  });
});

// Handle button clicks
app.action('deploy_confirm', async ({ action, ack, respond }) => {
  await ack();
  const branch = action.value;

  await respond({
    replace_original: true,
    text: `🚀 Deploying *${branch}*... This may take a few minutes.`,
  });

  // Trigger actual deployment
  await triggerDeploy(branch);

  await respond({
    text: `✅ *${branch}* deployed successfully!`,
  });
});

app.action('deploy_cancel', async ({ ack, respond }) => {
  await ack();
  await respond({
    replace_original: true,
    text: '❌ Deployment cancelled.',
  });
});

5. Modals

Open a Modal

app.command('/feedback', async ({ command, ack, client }) => {
  await ack();

  await client.views.open({
    trigger_id: command.trigger_id,
    view: {
      type: 'modal',
      callback_id: 'feedback_modal',
      title: { type: 'plain_text', text: 'Send Feedback' },
      submit: { type: 'plain_text', text: 'Submit' },
      blocks: [
        {
          type: 'input',
          block_id: 'feedback_type',
          element: {
            type: 'static_select',
            action_id: 'type_select',
            options: [
              { text: { type: 'plain_text', text: 'Bug Report' }, value: 'bug' },
              { text: { type: 'plain_text', text: 'Feature Request' }, value: 'feature' },
              { text: { type: 'plain_text', text: 'General' }, value: 'general' },
            ],
          },
          label: { type: 'plain_text', text: 'Type' },
        },
        {
          type: 'input',
          block_id: 'feedback_text',
          element: {
            type: 'plain_text_input',
            action_id: 'text_input',
            multiline: true,
            placeholder: { type: 'plain_text', text: 'Describe your feedback...' },
          },
          label: { type: 'plain_text', text: 'Feedback' },
        },
      ],
    },
  });
});

// Handle modal submission
app.view('feedback_modal', async ({ view, ack, client }) => {
  await ack();

  const type = view.state.values.feedback_type.type_select.selected_option?.value;
  const text = view.state.values.feedback_text.text_input.value;
  const userId = view.user.id;

  // Process feedback (save to database, create ticket, etc.)
  await saveFeedback({ type, text, userId });

  // Notify the user
  await client.chat.postMessage({
    channel: userId,
    text: `Thanks for your ${type} feedback! We'll review it shortly.`,
  });
});

6. Scheduled Messages

// Send a daily standup reminder
import cron from 'node-cron';

cron.schedule('0 9 * * 1-5', async () => {
  await app.client.chat.postMessage({
    channel: '#engineering',
    text: '🌅 *Daily Standup*\nWhat did you work on yesterday? What are you working on today? Any blockers?',
  });
});

// Schedule a one-time message
await app.client.chat.scheduleMessage({
  channel: '#general',
  post_at: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
  text: 'Reminder: Team meeting in 15 minutes!',
});

7. App Home Tab

app.event('app_home_opened', async ({ event, client }) => {
  await client.views.publish({
    user_id: event.user,
    view: {
      type: 'home',
      blocks: [
        {
          type: 'header',
          text: { type: 'plain_text', text: '🏠 Dashboard' },
        },
        {
          type: 'section',
          text: {
            type: 'mrkdwn',
            text: '*Quick Actions*',
          },
        },
        {
          type: 'actions',
          elements: [
            {
              type: 'button',
              text: { type: 'plain_text', text: '📊 System Status' },
              action_id: 'home_status',
            },
            {
              type: 'button',
              text: { type: 'plain_text', text: '🚀 Deploy' },
              action_id: 'home_deploy',
            },
          ],
        },
        { type: 'divider' },
        {
          type: 'section',
          text: {
            type: 'mrkdwn',
            text: '*Recent Deployments*\n• `main` → production — 2 hours ago ✅\n• `feature/auth` → staging — 5 hours ago ✅',
          },
        },
      ],
    },
  });
});

Production Deployment

Switch from Socket Mode to HTTP

For production, use HTTP mode instead of Socket Mode:

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  // Remove socketMode and appToken
});

await app.start(process.env.PORT || 3000);

Set your Request URL in Slack App settings to: https://your-server.com/slack/events

Production Checklist

ItemNotes
Use HTTP mode (not Socket Mode)Socket Mode is dev only
Set Request URL to your production serverRequired for events and commands
Enable retry on failureSlack retries failed deliveries 3 times
Respond within 3 secondsUse ack() immediately, then process async
Handle rate limits1 message per second per channel
Log all errorsSlack doesn't show your server errors

Common Mistakes

MistakeImpactFix
Not calling ack() within 3 secondsCommand appears to failAlways ack first, process after
Sending too many messagesRate limited (429)Queue messages, respect 1/sec limit
Not handling errorsBot silently failsWrap all handlers in try/catch
Hardcoding channel IDsBreaks in different workspacesUse channel names or let users choose
Not using threads for repliesClutters channelsReply with thread_ts

Handling Rate Limits at Scale

Slack's rate limits are per-method and per-workspace. The most important limits:

  • chat.postMessage: Tier 3 — ~1 message per second per channel. If you exceed this, Slack returns a 429 with a Retry-After header.
  • chat.update: Tier 3 — same limit as postMessage.
  • users.info, conversations.members: Tier 4 — 100 requests per minute.
  • Incoming webhooks: No explicit rate limit documented, but burst sending can trigger temporary blocks.

For bots that notify multiple channels (e.g., a deployment bot that posts to 20 team channels), you need a queue. A simple approach is p-queue with a concurrency of 1 and a 1-second interval. Initialize the queue once at module level and push all chat.postMessage calls through it. This guarantees you never exceed 1 message/second globally, not just per-channel. If you're posting to many channels simultaneously, the queue adds latency — acceptable for notification bots, unacceptable for time-sensitive alerts. For time-sensitive bots, track per-channel rate limit state separately and only queue when a channel is throttled.

Slack will return ratelimited in the error body for chat.postMessage failures. Check for this specifically rather than treating all 429s the same — some Slack 429s come from Slack's own infrastructure issues and have different retry semantics. The Bolt framework handles rate limit errors from event handlers but doesn't automatically retry messages you post via client.chat.postMessage. Implement your own retry logic with exponential backoff for proactive message sending.

Another common pitfall: users.info calls in a loop. If you're resolving 50 user IDs to display names, 50 sequential API calls hit the Tier 4 limit immediately. Batch with users.list (returns all users in pages), cache the results in memory with a 15-minute TTL, and look up locally. Most bots only have a few hundred users and the full list fits in memory easily.

Building Multi-Workspace Bots (Slack Apps)

If you're building a Slack app that others install (not just an internal bot for your workspace), you need OAuth and per-workspace token storage.

Each workspace that installs your app gets its own bot token. You can't use a single token across multiple workspaces. The OAuth flow: your app redirects users to Slack's OAuth page, Slack redirects back with a code, you exchange the code for a bot token using oauth.v2.access, and you store the token keyed by workspace ID (team.id).

When a user in workspace A triggers your bot, you look up workspace A's token, initialize a Bolt client with that token, and process the request. Bolt's built-in InstallProvider handles much of this, but you need to implement token storage yourself — typically a database table with (team_id, access_token, bot_user_id).

For bots that handle sensitive data, store tokens encrypted at rest. Rotate them when users revoke and reinstall your app (Slack sends an app_uninstalled event when a token is revoked). Monitor for token_revoked errors in your API calls — these indicate a workspace has uninstalled your app and you should clean up their data per your data retention policy.

Slack Workflow Builder Integration

Slack's Workflow Builder lets non-developers create automations using your bot as a step. For 2026, this is a meaningful distribution channel — ops, HR, and support teams build workflows in Workflow Builder without writing code, and your bot can be a step in those workflows.

To expose your bot as a Workflow Step, define a workflowStep() handler in Bolt. When a Workflow Builder action triggers your step, Bolt calls your handler with input variables from the workflow context. You complete the step by calling step.complete({ outputs }) or fail it with step.fail({ error }).

Workflow Step bots need the workflow.steps:execute scope. The registration is done in Slack's App configuration under "Workflow Steps." This feature is available to all Slack app developers and doesn't require Slack partner status. For internal bots, Workflow Builder integrations often eliminate the need to build custom admin UIs — let Slack's interface handle the configuration.

Note that Slack deprecated the original Workflow Steps for Apps (WSfA) API in favor of the new "steps from apps" model in Workflow Builder v2. The workflowStep() handler in Bolt v4 targets the new model. If you're seeing documentation about WorkflowStep class (capitalized, as a constructor), that's the old API. The new API uses the lowercase workflowStep() function. Slack's migration docs cover the differences if you're upgrading an existing bot.

Methodology

The Bolt for JavaScript examples in this guide are tested against v4.x (current stable as of 2026), the official Slack framework. Bolt is also available in Python (slack-bolt) and Java if your team's stack differs. The pattern — app.command(), app.action(), app.event() — is consistent across all three SDKs. This guide uses Bolt for JavaScript v4.x, the official Slack framework. Socket Mode is appropriate for development and internal bots running behind firewalls; HTTP mode is required for production Slack apps distributed to external workspaces. Rate limit data is sourced from Slack's official API reference (api.slack.com/docs/rate-limits). The cron scheduling example uses node-cron; for production bots with persistent schedules, prefer a database-backed job scheduler (Inngest, BullMQ) so schedules survive process restarts. @slack/bolt v4 requires Node.js 18+ and TypeScript 5.x for the best type inference experience. The Workflow Builder integration uses the workflowStep() API introduced in Bolt v3; prior versions used a separate WorkflowStep class.


Building communication tools? Compare Slack API vs Discord API on APIScout — bot platforms, features, and developer experience.

Related: How to Build a Discord Bot with TypeScript, Building a Communication Platform, How to Build an AI Chatbot with the Anthropic API

The API Integration Checklist (Free PDF)

Step-by-step checklist: auth setup, rate limit handling, error codes, SDK evaluation, and pricing comparison for 50+ APIs. Used by 200+ developers.

Join 200+ developers. Unsubscribe in one click.