Claude CodeでSlack Botを作る: 問い合わせ・障害一次対応・日次レポートまで育てる実装手順
Claude CodeとBolt JSでSlack Botを作る手順。Socket Mode、Slash Command、セキュリティまで解説。
通知botで終わらせない
Slack Botは、Slack上のメッセージ、Slash Command、ボタン、モーダル入力に反応して業務を進めるアプリです。Bolt for JavaScriptは、その受け口をNode.jsで書くための公式フレームワークです。言い換えると、Boltは「Slackから来たイベントを、どの関数に渡すか」を整理する足場です。
Claude Codeでの失敗は、通知だけを投げる小さなコードで満足することです。本当に役に立つbotは、問い合わせ受付、障害の一次対応、日次レポート、公開前チェックのように、Slack上の判断を構造化します。Masaの運用でも、最初の通知botは便利でしたが、担当者、緊急度、完了状態が残らず、結局チャンネルを読み返していました。
この記事は、2026年6月3日時点のSlack公式ドキュメントであるBolt for JavaScript、Boltのコマンド受信、Socket Mode、Slash Command、Events API、chat.postMessage、リクエスト署名検証、tokensを確認して書いています。周辺知識はWebhook実装、API開発、secrets管理、ワークフロー自動化も参考になります。
先に決めるユースケース
Claude Codeにいきなり「Slack Botを作って」と頼むと、薄いサンプルになりがちです。先に入口、保存項目、返信、失敗時の扱いを決めます。
| ユースケース | Slack上の入口 | botが行うこと | 注意点 |
|---|---|---|---|
| 問い合わせトリアージ | /triage add、モーダル | 件名、緊急度、依頼者をそろえて専用チャンネルに投稿する | 顧客名、秘密情報、未公開URLを貼らせない |
| 障害一次対応 | @botメンション、ボタン | 初動チェックリストを返し、担当者とスレッドを残す | botが断定回答しすぎないよう人間へ渡す条件を決める |
| 日次レポート | /triage list、定時ジョブ | 未完了項目をまとめ、朝会や日報に貼る | 件数が多い場合はSlackの長文制限を避ける |
| 記事・LP公開前チェック | Slash Command | CTA、内部リンク、担当者、公開URLを確認する | 下書きURLと本番URLを混ぜない |
概念図にすると、今回作るbotは次の流れです。
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"]
この段階でClaude Codeへ渡す依頼文は、次のように具体化します。
Bolt for JavaScriptでSlack Botを実装してください。
目的は問い合わせトリアージです。
含めるもの:
- Socket ModeとRequest URLを環境変数で切り替える
- /triage add, /triage list, /triage modal
- モーダル入力とview_submission
- Mark doneボタン
- app_mentionへの案内返信
- scopes、secrets、署名検証の説明
- triage.tsの単体テスト
疑似APIは使わず、コピペで動くTypeScriptにしてください。
Socket ModeとRequest URLの選び方
Socket Modeは、公開HTTPSエンドポイントを用意せず、bot側からSlackへWebSocket接続してイベントを受け取る方式です。ローカル開発や社内PoCに向き、xapp-で始まるApp-Level Tokenを使います。
Request URLは、Slackから自分のHTTPSエンドポイントへPOSTしてもらう方式です。本番運用ではこちらを選ぶ場面が多く、HTTPで受ける場合はSigning Secretで署名を検証します。Boltが検証を肩代わりしても、古いverification tokenに頼らない方針は残します。
| 方式 | 向いている場面 | 必要なもの | 落とし穴 |
|---|---|---|---|
| Socket Mode | ローカル開発、社内PoC | SLACK_APP_TOKEN、connections:write | プロセスが落ちると受信できない。Marketplace配布には向かない |
| Request URL | 本番HTTP運用 | HTTPS URL、SLACK_SIGNING_SECRET | ack()が遅いとSlack側でタイムアウトする |
最初はSocket Modeで動作確認し、外部ユーザーや本番チャンネルに出す段階でRequest URLへ移すと安全です。以下のコードはSLACK_SOCKET_MODE=trueで切り替えます。
Slackアプリ設定とscopes
Slackアプリのmanifestはリポジトリに置き、開発用と本番用で差分が見えるようにします。今回必要な権限は最小限です。commandsはSlash Commandを受けるため、chat:writeはメッセージ投稿のため、app_mentions:readはbotへのメンションを受けるために使います。
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
channels:historyやgroups:historyは最初から足しません。botが本当に履歴を読む設計になった時点で、プライバシーと監査を含めてレビューします。
ローカルプロジェクトを作る
Node.js 20以上を前提にします。以下はそのまま貼って試せる最小構成です。
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"]
}
.env.exampleも置きます。実値は.envやホスティング側のsecret managerに入れ、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-はbot token、xapp-はSocket Modeなどで使うApp-Level Tokenです。Signing SecretはHTTPリクエストがSlack由来か確かめる鍵です。Claude Codeには実値ではなく、変数名、使う場所、失敗時のログ方針だけ渡します。
コピペで動くBolt実装
まずSlackに依存しないロジックをsrc/triage.tsへ分けます。ここを分けると、Claude Codeにテストを追加させやすくなります。
// 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" },
],
},
},
],
};
}
次にBoltのlistenerをつなぎます。listenerは、Slash Command、モーダル送信、ボタン操作、メンションイベントを受ける関数です。
// 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}`}`);
最後に、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.");
});
});
実行は次の順です。
npm run test
npm run build
npm run dev
Socket Modeならnpm run devのままSlackで/triage add Test from Slackを打ちます。Request URLなら、デプロイ先のhttps://example.com/slack/eventsをSlash Command、Interactivity、Event Subscriptionsに設定します。
よくある落とし穴とセキュリティ
ack()を遅らせない。Slash Command、ボタン、モーダル送信では、DB更新や外部APIより先に受信確認を返します。trigger_idを長く保持しない。/triage modalを受けたら先にviews.openを呼び、詳細検証はview_submission側で行います。- 権限不足をコードだけで直そうとしない。
chat:write不足、bot未招待、app_mention未購読はSlack側の設定です。 - Socket ModeとRequest URLを混ぜない。起動ログに選択中のモードを出すだけでも切り分けが速くなります。
- secretを露出しない。
xoxb-、xapp-、Signing Secretをプロンプト、スクリーンショット、ログ、fixtureに貼らず、漏れたら即ローテーションします。 - botに判断させすぎない。障害や問い合わせでは、原因断定より「次に見る情報」と「人間へ渡す条件」を返します。
本番デプロイ前チェックリスト
- manifestのscopesとコードで使うAPIが一致している
/triageの名前が既存アプリと衝突していない- Interactivityが有効で、モーダルとボタンのpayloadを受けられる
- Botを投稿先チャンネルへ招待している
SLACK_BOT_TOKEN、SLACK_APP_TOKEN、SLACK_SIGNING_SECRETをsecret managerへ入れているnpm run testとnpm run buildが通る- Request URL運用ではHTTPSとSlack署名検証が有効
- Socket Mode運用ではプロセス監視と自動再起動がある
- ログにtoken、未マスクの個人情報、顧客名、非公開URLが出ない
- 障害時に誰へエスカレーションするかをチャンネル説明に書いている
Claude Codeに任せる範囲も分けます。manifest、Slack非依存ロジック、Bolt listener、単体テスト、デプロイチェックを別々にレビューさせると、設定ミスとコードバグを混ぜずに直せます。
ClaudeCodeLabでは、社内bot、Webhook、API、secrets管理を研修・相談で扱っています。自社向けのチェックリスト、CLAUDE.md、公開前レビューの型が必要なら教材・テンプレートも組み合わせると、botを運用改善や収益導線につなげやすくなります。
実際に試した結果
実際に試した結果、最短だったのは「大きなSlack Botを一気に作る」ことではありませんでした。先にmanifestとscopeを固定し、次にtriage.tsのような純粋なロジックを作り、最後にBolt listenerとSlack管理画面をつなぐ順番が安定しました。Claude Codeにはコード生成だけでなく、権限、secret、テスト、デプロイ前確認まで同じ作業単位で持たせるのが近道です。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。