Tutorials8 min read

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 @mentions in any channel with Claude-powered answers
  • Maintain conversation context within a Slack thread (up to 20 turns)
  • Accept a /ask slash 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

Time to complete: ~45 minutes Prerequisites: Node.js 18+, a Slack workspace where you can install apps, Anthropic API key

Step 1: Create the Slack App

Before writing code, set up the Slack app configuration.

  • Go to api.slack.com/apps and click Create New App → From Scratch
  • Name it (e.g., "Claude Assistant") and select your workspace
  • Under OAuth & Permissions, add these Bot Token Scopes:
  • - app_mentions:read — detect when the bot is mentioned

    - channels:history — read thread context

    - chat:write — post messages

    - commands — slash command support

  • Under Event Subscriptions, enable events and add:
  • - app_mention — fires when someone @-mentions your bot

  • Under Slash Commands, create /ask pointing to your server's /slack/events endpoint
  • Save 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 dotenv

    Create .env:

    envSLACK_BOT_TOKEN=xoxb-your-token-here
    SLACK_SIGNING_SECRET=your-signing-secret-here
    ANTHROPIC_API_KEY=sk-ant-your-key-here
    PORT=3000

    Create 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:

  • Reads the thread context (if any)
  • Adds the new user message
  • Calls Claude API with full context
  • Posts the response back into the same thread
  • 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;
    }

    Key model choice: 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,
        });
      }
    });

    Note: Slack slash commands have a 3-second acknowledgment window. Always call 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.js

    For local development, use ngrok to expose your localhost:

    bashngrok http 3000

    Paste 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:

    ConcernRecommendation
    HostingRailway, Render, or Fly.io — all support always-on Node.js with free tiers
    Context storageMove threadContext Map to Redis for multi-instance deployments
    Rate limitsClaude Sonnet handles ~1,000 req/min; add a per-user queue for busy workspaces
    SecretsUse the platform's secret manager, never commit .env
    LoggingAdd structured logging (Pino or Winston) before going to production
    Error alertingWire 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 mentions

    Check that app_mention is listed under Event Subscriptions and that the bot has been added to the channel with /invite @Claude Assistant.

    "dispatch_failed" from Slack

    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 channels

    Threads isolate context correctly because we key by thread_ts. If you want per-user isolation instead, key by ${channel}-${user} instead.

    Responses too long for Slack

    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 ts is 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.