Use Cases (Updated: 6/3/2026)

Build a Claude Code Slack Bot: Triage, Incident First Response, and Daily Reports

Build a Slack bot with Claude Code and Bolt JS: Socket Mode, slash commands, security, tests, and production checks.

Build a Claude Code Slack Bot: Triage, Incident First Response, and Daily Reports

Do Not Stop at Notifications

A Slack Bot is an app that reacts to messages, slash commands, buttons, and modal submissions inside Slack. Bolt for JavaScript is Slack’s official Node.js framework for routing those events to the right handler. In beginner terms, Bolt is the scaffold that lets you say, “when this Slack event arrives, run this function.”

The common Claude Code mistake is to stop at a small notification bot. A useful bot turns channel noise into structured work: support triage, incident first response, daily reports, approval requests, and pre-publish checks. In Masa’s own site and team workflows, a one-way notification bot was helpful for a week, but it did not answer who owned the item, how urgent it was, or whether it had been closed. The channel still had to be reread by hand.

This guide was checked against the official Slack docs on June 3, 2026: Bolt for JavaScript, Bolt command listeners, Socket Mode, slash commands, Events API, chat.postMessage, verifying requests, and tokens. For adjacent ClaudeCodeLab material, pair it with webhook implementation, API development, secrets management, and workflow automation.

Pick Use Cases First

If you ask Claude Code to “build a Slack bot” without a workflow, the result is usually a thin demo. Decide the Slack entry point, the fields to collect, the message to return, and the failure behavior first.

Use caseSlack entry pointWhat the bot doesRisk to control
Support triage/triage add, modalNormalizes title, severity, requester, and channel notificationUsers paste customer names, secrets, or private URLs
Incident first response@bot mention, buttonReturns the first checklist and keeps owner context in a threadThe bot sounds too confident instead of escalating
Daily report/triage list, scheduled jobSummarizes open items for standup or daily notesLong messages hit Slack readability and length limits
Article or landing page checksSlash commandChecks CTA, internal links, owner, and publish URLDraft URLs and production URLs get mixed

The architecture is intentionally small.

flowchart LR
  A["Slack user"] --> B["/triage or @mention"]
  B --> C["Bolt listener"]
  C --> D["Triage logic"]
  D --> E["chat.postMessage"]
  D --> F["Modal and button"]

Give Claude Code a concrete request like this:

Implement a Slack Bot with Bolt for JavaScript.
The goal is support triage.
Include:
- Switch between Socket Mode and Request URL with environment variables
- /triage add, /triage list, /triage modal
- Modal input and view_submission handling
- A Mark done button
- Guidance reply for app_mention
- Explanations for scopes, secrets, and request verification
- Unit tests for triage.ts
Do not use pseudo APIs. Write copy-paste runnable TypeScript.

Socket Mode or Request URL

Socket Mode receives events over a WebSocket connection initiated by your app, so you do not need a public HTTPS endpoint for local development. It is useful for prototypes, internal tools, and environments behind a firewall. Slack’s docs describe enabling Socket Mode and using an app-level token that starts with xapp-.

Request URL mode receives Slack HTTP POST requests at your HTTPS endpoint. This is the common production pattern for hosted apps. When you receive Slack over HTTP, verify the request with the Signing Secret. Bolt can do that verification for you when configured, but the design note should still say, “do not rely on deprecated verification tokens.”

ModeBest forRequired setupPitfall
Socket ModeLocal development, internal PoCSLACK_APP_TOKEN, connections:writeNo events arrive if the process is down; not ideal for Marketplace distribution
Request URLProduction HTTP deploymentHTTPS URL, SLACK_SIGNING_SECRETSlow ack() calls surface as Slack timeouts

Start with Socket Mode, then move to Request URL when the bot touches production channels or external users. The code below switches with SLACK_SOCKET_MODE=true.

Slack App Manifest and Scopes

Keep the manifest in version control so dev and production apps do not drift. The scopes here are deliberately small: commands receives the slash command, chat:write posts messages, and app_mentions:read receives mentions.

display_information:
  name: Claude Triage Bot
  description: Collect triage requests from Slack
  background_color: "#2E2A24"
features:
  bot_user:
    display_name: Claude Triage
    always_online: false
  slash_commands:
    - command: /triage
      description: Add or list triage items
      usage_hint: "add Fix login | list | modal"
      should_escape: true
oauth_config:
  scopes:
    bot:
      - app_mentions:read
      - chat:write
      - commands
settings:
  event_subscriptions:
    bot_events:
      - app_mention
  interactivity:
    is_enabled: true
  socket_mode_enabled: true
  org_deploy_enabled: false
  token_rotation_enabled: false

Do not add channels:history or groups:history just because they look useful. Add history-reading scopes only when the bot actually reads channel history and someone has reviewed the privacy impact.

Create the Local Project

Assume Node.js 20 or newer.

mkdir claude-slack-triage-bot
cd claude-slack-triage-bot
npm init -y
npm install @slack/bolt @slack/types dotenv
npm install -D typescript tsx vitest @types/node
npm pkg set type=module
npm pkg set scripts.dev="tsx watch src/app.ts"
npm pkg set scripts.build="tsc"
npm pkg set scripts.start="node dist/app.js"
npm pkg set scripts.test="vitest run"
mkdir src tests
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}

Add .env.example. Put real values in .env or your host’s secret manager, never in Git.

SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_SIGNING_SECRET=your-signing-secret
SLACK_SOCKET_MODE=true
SLACK_APP_TOKEN=xapp-your-app-level-token
TRIAGE_CHANNEL_ID=C0123456789
PORT=3000

xoxb- is the bot token. xapp- is the app-level token used by Socket Mode. The Signing Secret proves HTTP requests came from Slack. Claude Code does not need those values; it needs variable names, expected behavior, and logging rules.

Copy-Paste Bolt Implementation

First, isolate Slack-independent logic in src/triage.ts. That makes the behavior testable without a real Slack workspace.

// src/triage.ts
import type { KnownBlock, View } from "@slack/types";

export type Severity = "low" | "normal" | "high";

export interface Ticket {
  id: string;
  channelId: string;
  title: string;
  createdBy: string;
  severity: Severity;
  status: "open" | "done";
  createdAt: string;
}

const tickets = new Map<string, Ticket>();

export function resetForTest() {
  tickets.clear();
}

export function parseTriageText(text: string) {
  const [actionRaw, ...rest] = text.trim().split(/\s+/);
  return { action: actionRaw || "help", title: rest.join(" ").trim() };
}

export function addTicket(input: {
  channelId: string;
  title: string;
  createdBy: string;
  severity?: Severity;
}) {
  const ticket: Ticket = {
    id: `triage_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
    channelId: input.channelId,
    title: input.title,
    createdBy: input.createdBy,
    severity: input.severity ?? "normal",
    status: "open",
    createdAt: new Date().toISOString(),
  };
  tickets.set(ticket.id, ticket);
  return ticket;
}

export function completeTicket(id: string) {
  const ticket = tickets.get(id);
  if (!ticket) return undefined;
  const updated: Ticket = { ...ticket, status: "done" };
  tickets.set(id, updated);
  return updated;
}

export function formatTicketList(channelId: string) {
  const open = [...tickets.values()].filter((ticket) => {
    return ticket.channelId === channelId && ticket.status === "open";
  });

  if (open.length === 0) return "No open triage items.";

  return open
    .map((ticket, index) => {
      return `${index + 1}. [${ticket.severity}] ${ticket.title} by <@${ticket.createdBy}>`;
    })
    .join("\n");
}

export function ticketBlocks(ticket: Ticket): KnownBlock[] {
  return [
    {
      type: "section",
      text: {
        type: "mrkdwn",
        text: `*${ticket.title}*\nSeverity: ${ticket.severity}\nOwner: <@${ticket.createdBy}>`,
      },
    },
    {
      type: "actions",
      elements: [
        {
          type: "button",
          text: { type: "plain_text", text: "Mark done" },
          action_id: "triage_done",
          value: ticket.id,
        },
      ],
    },
  ];
}

export function modalView(): View {
  return {
    type: "modal",
    callback_id: "triage_modal_submit",
    title: { type: "plain_text", text: "New triage" },
    submit: { type: "plain_text", text: "Create" },
    close: { type: "plain_text", text: "Cancel" },
    blocks: [
      {
        type: "input",
        block_id: "title_block",
        label: { type: "plain_text", text: "What needs attention?" },
        element: {
          type: "plain_text_input",
          action_id: "title_input",
          min_length: 3,
          max_length: 120,
        },
      },
      {
        type: "input",
        block_id: "severity_block",
        label: { type: "plain_text", text: "Severity" },
        element: {
          type: "static_select",
          action_id: "severity_input",
          initial_option: {
            text: { type: "plain_text", text: "Normal" },
            value: "normal",
          },
          options: [
            { text: { type: "plain_text", text: "High" }, value: "high" },
            { text: { type: "plain_text", text: "Normal" }, value: "normal" },
            { text: { type: "plain_text", text: "Low" }, value: "low" },
          ],
        },
      },
    ],
  };
}

Now connect the Bolt listeners.

// src/app.ts
import "dotenv/config";
import { App, LogLevel } from "@slack/bolt";
import {
  addTicket,
  completeTicket,
  formatTicketList,
  modalView,
  parseTriageText,
  ticketBlocks,
  type Severity,
} from "./triage.js";

const socketMode = process.env.SLACK_SOCKET_MODE === "true";
const required = ["SLACK_BOT_TOKEN", socketMode ? "SLACK_APP_TOKEN" : "SLACK_SIGNING_SECRET"];

for (const key of required) {
  if (!process.env[key]) throw new Error(`Missing environment variable: ${key}`);
}

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  socketMode,
  appToken: process.env.SLACK_APP_TOKEN,
  logLevel: LogLevel.INFO,
});

app.command("/triage", async ({ ack, command, respond, client }) => {
  await ack();
  const parsed = parseTriageText(command.text);

  if (parsed.action === "add") {
    if (!parsed.title) {
      await respond("Usage: `/triage add Fix login redirect`");
      return;
    }

    const ticket = addTicket({
      channelId: command.channel_id,
      title: parsed.title,
      createdBy: command.user_id,
      severity: "normal",
    });

    await respond({
      response_type: "in_channel",
      text: `Triage item added: ${ticket.title}`,
      blocks: ticketBlocks(ticket),
    });
    return;
  }

  if (parsed.action === "list") {
    await respond({ response_type: "ephemeral", text: formatTicketList(command.channel_id) });
    return;
  }

  if (parsed.action === "modal") {
    await client.views.open({ trigger_id: command.trigger_id, view: modalView() });
    return;
  }

  await respond("Usage: `/triage add ...`, `/triage list`, or `/triage modal`");
});

app.view("triage_modal_submit", async ({ ack, view, body, client }) => {
  const titleState = view.state.values.title_block.title_input;
  const severityState = view.state.values.severity_block.severity_input;
  const title = titleState.type === "plain_text_input" ? titleState.value?.trim() : "";
  const severity =
    severityState.type === "static_select"
      ? severityState.selected_option?.value ?? "normal"
      : "normal";

  if (!title) {
    await ack({ response_action: "errors", errors: { title_block: "Please enter a title." } });
    return;
  }

  await ack();

  const channelId = process.env.TRIAGE_CHANNEL_ID ?? "modal-only";
  const ticket = addTicket({
    channelId,
    title,
    createdBy: body.user.id,
    severity: severity as Severity,
  });

  if (process.env.TRIAGE_CHANNEL_ID) {
    await client.chat.postMessage({
      channel: process.env.TRIAGE_CHANNEL_ID,
      text: `New triage item: ${ticket.title}`,
      blocks: ticketBlocks(ticket),
    });
  }
});

app.action("triage_done", async ({ ack, action, respond }) => {
  await ack();
  const value = action.type === "button" ? action.value : undefined;
  if (!value) return;

  const ticket = completeTicket(value);
  await respond(ticket ? `Closed: ${ticket.title}` : "Ticket not found.");
});

app.event("app_mention", async ({ event, say }) => {
  await say({
    thread_ts: event.ts,
    text: "Use `/triage add ...`, `/triage list`, or `/triage modal`.",
  });
});

const port = Number(process.env.PORT ?? 3000);
if (socketMode) {
  await app.start();
} else {
  await app.start(port);
}

app.logger.info(`Slack bot started in ${socketMode ? "Socket Mode" : `HTTP mode on ${port}`}`);

Add unit tests that do not need Slack.

// tests/triage.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import {
  addTicket,
  completeTicket,
  formatTicketList,
  parseTriageText,
  resetForTest,
} from "../src/triage";

describe("triage helpers", () => {
  beforeEach(() => resetForTest());

  it("parses slash command text", () => {
    expect(parseTriageText("add Fix login")).toEqual({
      action: "add",
      title: "Fix login",
    });
  });

  it("lists only open tickets", () => {
    const ticket = addTicket({
      channelId: "C123",
      title: "Review pricing CTA",
      createdBy: "U123",
      severity: "high",
    });

    expect(formatTicketList("C123")).toContain("[high] Review pricing CTA");
    completeTicket(ticket.id);
    expect(formatTicketList("C123")).toBe("No open triage items.");
  });
});

Run:

npm run test
npm run build
npm run dev

In Socket Mode, keep npm run dev running and type /triage add Test from Slack in Slack. In Request URL mode, deploy the app and configure https://example.com/slack/events for slash commands, interactivity, and event subscriptions.

Pitfalls and Security Notes

First, call ack() before slow work. Slash commands, buttons, and modal submissions should acknowledge receipt before database writes or external API calls.

Second, treat trigger_id as short-lived. Open the modal first, then validate the detailed input inside view_submission.

Third, do not debug permission problems only in code. Missing chat:write, a bot not invited to the channel, or a missing app_mention subscription must be fixed in Slack settings.

Fourth, do not mix modes. Socket Mode needs SLACK_APP_TOKEN; Request URL mode needs HTTPS and SLACK_SIGNING_SECRET. Log the selected mode at startup.

Fifth, never expose secrets. Do not paste xoxb-, xapp-, or the Signing Secret into Claude Code prompts, screenshots, logs, fixtures, or articles. Rotate immediately if they leak.

Finally, avoid giving the bot too much judgment. For incident response and support, the bot should provide the next checks and escalation rule, not pretend to know the root cause.

Production Checklist

  • Manifest scopes match the APIs used in code.
  • /triage does not conflict with another installed app.
  • Interactivity is enabled for modals and buttons.
  • The bot is invited to the destination channel.
  • SLACK_BOT_TOKEN, SLACK_APP_TOKEN, and SLACK_SIGNING_SECRET are stored as secrets.
  • npm run test and npm run build pass.
  • Request URL deployment uses HTTPS and Slack signature verification.
  • Socket Mode deployment has process monitoring and restart.
  • Logs do not include tokens, unmasked personal data, customer names, or private URLs.
  • The channel topic explains who handles escalation when the bot cannot.

Split what Claude Code owns: manifest and scope proposal, Slack-independent logic, Bolt listeners, unit tests, and deployment checklist. That keeps Slack configuration mistakes separate from code defects.

ClaudeCodeLab covers this kind of internal bot, webhook, API, and secrets workflow in training and consultation. If you want reusable CLAUDE.md rules, pre-publish review templates, and team checklists, combine the bot pattern with templates and products so the work supports operations and revenue instead of remaining a one-off demo.

What I Found in Practice

The fastest route was not generating a large Slack bot in one shot. The stable path was to freeze the manifest and scopes first, write pure logic like triage.ts second, then connect Bolt listeners and Slack admin settings last. Claude Code works best when it owns code generation, permissions, secrets, tests, and deployment checks as one reviewable unit.

#Claude Code #Slack Bot #Bolt JS #slash commands #workflow automation
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.