How to Build a Claude-Powered Slack Bot
Building a Claude-powered Slack bot means connecting Slack's Events API to an AI backend that calls Claude, then posting the response back into a channel or thread. The core architecture is simple: Slack sends your server an event when someone mentions the bot or sends it a direct message, your server forwards that message to Claude, and you reply with chat.postMessage. The hard part is handling Slack's 3-second acknowledgment window, threading context correctly, and keeping your API key out of client code.
This guide walks through the full setup — Slack app configuration, the Bolt.js event handler, and the Claude API call — plus the gotchas that trip people up when they move from a weekend prototype to something a team actually uses.
What you need before starting
- A Slack workspace where you can install custom apps
- A Slack app with a Bot Token (
xoxb-...) and Socket Mode or a public HTTPS endpoint for Events API - An API key for Claude — either directly from Anthropic or through a proxy like SubToAPI if you want a standard HTTPS endpoint, usage dashboards and team seat management without managing Anthropic billing yourself
- Node.js (examples use
@slack/bolt, but the same logic works in Python withslack_bolt)
Step 1: Create the Slack app
- Go to api.slack.com/apps and create a new app.
- Under OAuth & Permissions, add the
chat:write,app_mentions:read, andim:historyscopes. - Under Event Subscriptions, enable events and subscribe to
app_mentionandmessage.im. - Install the app to your workspace and copy the Bot User OAuth Token.
If you don't want to expose a public URL during development, enable Socket Mode instead — it avoids the need for ngrok or a deployed server just to test.
Step 2: Set up the Bolt app
const { App } = require('@slack/bolt');
const app = new App({
token: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET,
socketMode: true,
appToken: process.env.SLACK_APP_TOKEN,
});
app.event('app_mention', async ({ event, say }) => {
const userText = event.text.replace(/<@[^>]+>/, '').trim();
const reply = await askClaude(userText, event.thread_ts || event.ts);
await say({ text: reply, thread_ts: event.ts });
});
app.start(process.env.PORT || 3000).then(() => {
console.log('Slack bot running');
});
The key detail here: always reply in a thread (thread_ts: event.ts). Without it, every exchange clutters the main channel and your bot loses conversational context visually, even if you're tracking it server-side.
Step 3: Call Claude from the event handler
This is where most of the actual work happens. You need a function that takes the user's message, optionally pulls prior thread history for context, and sends it to Claude.
async function askClaude(userText, threadId) {
const response = await fetch('https://api.subtoapi.app/v1/messages', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.SUBTOAPI_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'claude-sonnet-4-5',
max_tokens: 1024,
messages: [
{ role: 'user', content: userText },
],
}),
});
const data = await response.json();
return data.content[0].text;
}
If you're using SubToAPI instead of calling Anthropic directly, you get an API key in the sub_live_... format that works with your existing Claude access and keeps request logs, token usage and errors in one dashboard — useful once more than one person on your team is maintaining the bot. Full request/response shape is in the docs, and the quickstart covers getting a key in under five minutes.
Step 4: Handle Slack's 3-second rule
Slack expects an HTTP 200 within 3 seconds of sending an event, but Claude generations — especially longer ones — routinely take longer than that. Bolt acknowledges events automatically in most cases, but if you're doing anything blocking before calling say(), you risk Slack retrying the event and your bot replying twice.
The safe pattern: acknowledge immediately, then do the Claude call and post the result asynchronously.
app.event('app_mention', async ({ event, ack, say }) => {
if (ack) await ack();
const userText = event.text.replace(/<@[^>]+>/, '').trim();
// Post a "thinking" placeholder so the user knows it's working
const placeholder = await say({ text: '_thinking…_', thread_ts: event.ts });
const reply = await askClaude(userText, event.thread_ts || event.ts);
await app.client.chat.update({
token: process.env.SLACK_BOT_TOKEN,
channel: event.channel,
ts: placeholder.ts,
text: reply,
});
});
Editing a placeholder message into the final answer gives users immediate feedback and avoids the "is this thing broken?" moment during longer generations.
Step 5: Maintain conversation context
Slack threads are a natural fit for Claude's conversation format. Store the last N messages per thread_ts (in Redis, a database, or even an in-memory map for low-traffic bots) and pass them as the messages array on each call, alternating user and assistant roles. This lets the bot answer follow-up questions like "what about last week's numbers?" without the user repeating context.
For bots that need to pull live data — ticket counts, deployment status, calendar lookups — before answering, use Claude's tool use feature instead of trying to stuff everything into the prompt. The tool use docs cover the request format for defining functions Claude can call mid-conversation.
Step 6: Streaming for long responses (optional)
Slack doesn't support token-by-token streaming into a single message the way a chat UI does, but you can still use streaming on the backend to start editing the placeholder message as soon as the first chunk arrives, rather than waiting for the full generation. This shaves perceived latency noticeably on longer answers. See the streaming docs for the SSE format if you want to implement incremental message updates.
Production checklist
- Rate-limit per channel to avoid runaway costs if someone spams mentions
- Log token usage per workspace if you're running this for multiple teams
- Set a reasonable
max_tokens— Slack messages rarely need more than 500–1000 tokens - Rotate your API key periodically and never commit it; SubToAPI lets you generate and revoke keys per environment from the pricing dashboard without touching Anthropic's console
questions
Do I need a paid Slack plan to run a Claude bot? No. Custom Slack apps with bot tokens work on free workspaces. You only need a paid Slack plan for things unrelated to the bot itself, like extended message history for human users.
Can the bot respond in DMs as well as channels? Yes — subscribe to the message.im event instead of (or alongside) app_mention, and make sure your bot has the im:history and chat:write scopes.
How do I keep API costs predictable? Cap max_tokens per request, cache repeated questions where possible, and monitor usage through your API provider's dashboard — SubToAPI shows per-key request and token counts so you can spot a runaway thread before it becomes a surprise bill.