Skip to main content

How to Build a Slack Bot from Scratch

·APIScout Team
slack apislack botchatbottutorialapi integration

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.

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

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

Comments