How to Build an AI-Powered Slack Bot with Claude API (2026 Tutorial)
Step-by-step guide to building a production-ready Slack bot powered by Claude API. Covers Bolt.js setup, slash commands, message threading, context memory, and deployment.
How to Build an AI-Powered Slack Bot with Claude API
Every developer eventually gets the request: "Can we add an AI assistant to our Slack workspace?" The challenge isn't the idea — it's building something that actually works in production: handles conversation context, responds quickly, and doesn't hallucinate your company's internal processes.
In this tutorial you'll build a production-ready Slack bot powered by Claude's API. By the end you'll have a bot that responds to mentions, maintains per-thread conversation history, supports slash commands, and handles rate limits gracefully — all in under 300 lines of Node.js.
What You'll Build
The finished bot will:
- Respond to
@mentionsin any channel with Claude-powered answers - Maintain conversation context within a Slack thread (up to 20 turns)
- Accept a
/askslash command for quick one-off questions - Store nothing permanently — context lives in the thread, not a database
- Handle Claude API errors and retry on 529 overload responses
Step 1: Create the Slack App
Before writing code, set up the Slack app configuration.
- app_mentions:read — detect when the bot is mentioned
- channels:history — read thread context
- chat:write — post messages
- commands — slash command support
- app_mention — fires when someone @-mentions your bot
/ask pointing to your server's /slack/events endpointSave the Bot User OAuth Token (xoxb-...) and Signing Secret — you'll need both.
Step 2: Project Setup
Initialize the project and install dependencies:
bashmkdir claude-slack-bot && cd claude-slack-bot
npm init -y
npm install @slack/bolt @anthropic-ai/sdk dotenvCreate .env:
envSLACK_BOT_TOKEN=xoxb-your-token-here
SLACK_SIGNING_SECRET=your-signing-secret-here
ANTHROPIC_API_KEY=sk-ant-your-key-here
PORT=3000Create index.js — this is the entire bot in one file:
javascriptrequire('dotenv').config();
const { App } = require('@slack/bolt');
const Anthropic = require('@anthropic-ai/sdk');
const app = new App({
token: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET,
});
const claude = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
// In-memory thread context store
// Key: thread_ts, Value: array of {role, content} messages
const threadContext = new Map();
const MAX_CONTEXT_TURNS = 20;
const CONTEXT_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
// Clean up stale context entries every hour
setInterval(() => {
const cutoff = Date.now() - CONTEXT_TTL_MS;
for (const [key, value] of threadContext.entries()) {
if (value.lastUpdated < cutoff) threadContext.delete(key);
}
}, 60 * 60 * 1000);Step 3: Handle @Mentions with Thread Context
This is the core of the bot — when someone mentions @Claude Assistant, it:
javascriptapp.event('app_mention', async ({ event, say, client }) => {
const threadTs = event.thread_ts || event.ts;
const userMessage = event.text.replace(/<@[A-Z0-9]+>/g, '').trim();
if (!userMessage) {
await say({ text: 'What can I help you with?', thread_ts: threadTs });
return;
}
// Load or initialize thread context
let context = threadContext.get(threadTs) || { messages: [], lastUpdated: 0 };
// Add current message
context.messages.push({ role: 'user', content: userMessage });
// Trim to max turns (keep most recent)
if (context.messages.length > MAX_CONTEXT_TURNS * 2) {
context.messages = context.messages.slice(-MAX_CONTEXT_TURNS * 2);
}
// Show typing indicator
await client.reactions.add({
channel: event.channel,
timestamp: event.ts,
name: 'thinking_face',
}).catch(() => {}); // ignore if reaction fails
try {
const response = await callClaudeWithRetry(context.messages);
const assistantMessage = response.content[0].text;
// Save updated context
context.messages.push({ role: 'assistant', content: assistantMessage });
context.lastUpdated = Date.now();
threadContext.set(threadTs, context);
await say({ text: assistantMessage, thread_ts: threadTs });
} catch (error) {
console.error('Claude API error:', error);
await say({
text: '⚠️ I hit an error. Please try again in a moment.',
thread_ts: threadTs,
});
} finally {
// Remove thinking reaction
await client.reactions.remove({
channel: event.channel,
timestamp: event.ts,
name: 'thinking_face',
}).catch(() => {});
}
});Step 4: The Claude API Wrapper with Retry Logic
Production bots need to handle Claude's occasional 529 overload errors. This wrapper retries up to 3 times with exponential backoff:
javascriptasync function callClaudeWithRetry(messages, maxRetries = 3) {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await claude.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 1024,
system: `You are a helpful AI assistant integrated into a Slack workspace.
Keep responses concise and well-formatted for Slack (avoid markdown headers,
use plain text or minimal formatting). Be direct and practical.`,
messages,
});
return response;
} catch (error) {
lastError = error;
// Retry on overload (529) or rate limit (429)
if (error.status === 529 || error.status === 429) {
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 500;
console.warn(`Claude API ${error.status}, retrying in ${delay}ms...`);
await new Promise(r => setTimeout(r, delay));
continue;
}
// Don't retry on other errors
throw error;
}
}
throw lastError;
}claude-sonnet-4-6 hits the right balance of speed and capability for chat. For heavier tasks (summarizing long docs, code review), swap to claude-opus-4-6.
Step 5: Add the /ask Slash Command
Slash commands are great for quick, context-free questions that don't need a thread:
javascriptapp.command('/ask', async ({ command, ack, respond }) => {
await ack(); // Acknowledge within 3 seconds (Slack requirement)
const question = command.text.trim();
if (!question) {
await respond({ text: 'Usage: `/ask your question here`', response_type: 'ephemeral' });
return;
}
// Immediate response to avoid Slack timeout
await respond({
text: '🤔 Thinking...',
response_type: 'in_channel',
});
try {
const response = await callClaudeWithRetry([
{ role: 'user', content: question }
]);
await respond({
text: response.content[0].text,
response_type: 'in_channel',
replace_original: true,
});
} catch (error) {
await respond({
text: '⚠️ Error reaching Claude. Try again.',
response_type: 'ephemeral',
replace_original: true,
});
}
});ack() first, then do the actual work. The replace_original: true swaps the "Thinking..." message with the real answer.
Step 6: Start the Server
javascript(async () => {
await app.start(process.env.PORT || 3000);
console.log(`⚡ Claude Slack Bot running on port ${process.env.PORT || 3000}`);
})();Start it:
bashnode index.jsFor local development, use ngrok to expose your localhost:
bashngrok http 3000Paste the ngrok URL into your Slack app's Event Subscriptions and Slash Command URLs (e.g., https://abc123.ngrok.io/slack/events).
Production Deployment Checklist
Once the bot works locally, here's what to sort before deploying:
| Concern | Recommendation |
|---|---|
| Hosting | Railway, Render, or Fly.io — all support always-on Node.js with free tiers |
| Context storage | Move threadContext Map to Redis for multi-instance deployments |
| Rate limits | Claude Sonnet handles ~1,000 req/min; add a per-user queue for busy workspaces |
| Secrets | Use the platform's secret manager, never commit .env |
| Logging | Add structured logging (Pino or Winston) before going to production |
| Error alerting | Wire errors to your own #alerts Slack channel via Bolt's error handler |
For Redis-backed context, replace the in-memory Map with:
javascriptconst { createClient } = require('redis');
const redis = createClient({ url: process.env.REDIS_URL });
async function getContext(threadTs) {
const raw = await redis.get(`thread:${threadTs}`);
return raw ? JSON.parse(raw) : { messages: [] };
}
async function saveContext(threadTs, context) {
await redis.setEx(`thread:${threadTs}`, 14400, JSON.stringify(context)); // 4hr TTL
}Common Issues and Fixes
Bot doesn't respond to mentionsCheck that app_mention is listed under Event Subscriptions and that the bot has been added to the channel with /invite @Claude Assistant.
Your server URL is unreachable. Verify ngrok is running (for dev) or your deployment URL is live and pointing to the correct port.
Context gets confused in busy channelsThreads isolate context correctly because we key by thread_ts. If you want per-user isolation instead, key by ${channel}-${user} instead.
Add a system prompt instruction: "Keep responses under 400 words. Use plain text, not markdown headers." Slack renders some markdown but not all.
Key Takeaways
- Slack's Bolt.js framework handles all the OAuth, signature verification, and event routing — focus on the Claude integration
- Thread
tsis a reliable key for maintaining conversation context without a database - Always call
ack()within 3 seconds for slash commands; use deferred responses for longer tasks - The retry wrapper is essential in production — Claude occasionally returns 529 under load
- Redis replaces the in-memory Map when you scale beyond a single server process
Next Steps
Ready to take your Claude skills further? The Claude Certified Architect exam covers API patterns like function calling, streaming, multi-turn conversations, and production deployment — exactly the skills you used in this tutorial.
👉 Take a free CCA practice test on AI for Anything and see where you stand.
Want to go deeper? Read our guides on Claude tool use and function calling and building multi-agent systems with Claude for the next level of Slack bot capability — like letting your bot query databases or call internal APIs on behalf of users.
Ready to Start Practicing?
300+ scenario-based practice questions covering all 5 CCA domains. Detailed explanations for every answer.
Free CCA Study Kit
Get domain cheat sheets, anti-pattern flashcards, and weekly exam tips. No spam, unsubscribe anytime.