← Blog

How to Build a Slack Bot With Claude API

2026-10-27 · 5 min read · SubToAPI Team

Building a Slack bot with Claude comes down to three pieces: a Slack app that receives events, a backend that forwards those events to Claude and gets a response, and a way to post that response back into the channel or thread. None of this is exotic — Slack's Events API gives you a webhook, Claude gives you a messages endpoint, and the glue code is maybe 80 lines.

This guide walks through the whole flow: creating the Slack app, handling mentions and DMs, calling Claude for a reply, keeping conversation context per thread, and optionally streaming responses so the bot feels responsive instead of silent for 10 seconds.

Set up the Slack app

  1. Go to api.slack.com/apps and create a new app "from scratch."
  2. Under OAuth & Permissions, add these bot scopes: app_mentions:read, chat:write, channels:history, im:history, im:write.
  3. Under Event Subscriptions, enable events and subscribe to app_mention and message.im (for DMs).
  4. Install the app to your workspace and copy the Bot User OAuth Token (xoxb-...).
  5. Set a Request URL for events — this needs to be a public HTTPS endpoint, so during development use ngrok or a similar tunnel.

Slack sends a verification challenge the first time you set the URL; your server needs to echo it back as plain text.

Build the event receiver

A minimal Node/Express handler for Slack events looks like this:

import express from "express";
import { WebClient } from "@slack/web-api";

const app = express();
app.use(express.json());

const slack = new WebClient(process.env.SLACK_BOT_TOKEN);

app.post("/slack/events", async (req, res) => {
  const { type, challenge, event } = req.body;

  if (type === "url_verification") {
    return res.send(challenge);
  }

  res.sendStatus(200); // ack immediately, Slack retries if you're slow

  if (event?.type === "app_mention" || event?.type === "message") {
    if (event.bot_id) return; // ignore the bot's own messages
    await handleMessage(event);
  }
});

app.listen(3000);

Acknowledging within 3 seconds matters — Slack retries the webhook if it doesn't get a fast 200, which can lead to duplicate replies if your Claude call is slow. Respond first, then process asynchronously.

Call Claude for a reply

This is where the API call to generate the bot's response happens. You can call Anthropic's API directly, or route through SubToAPI if you want application-level keys, usage metadata per bot/channel, and a dashboard instead of managing raw provider credentials. The request shape is the same either way:

async function handleMessage(event) {
  const userText = event.text.replace(/<@[^>]+>/g, "").trim();

  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();
  const reply = data.content[0].text;

  await slack.chat.postMessage({
    channel: event.channel,
    thread_ts: event.thread_ts || event.ts,
    text: reply,
  });
}

Posting with thread_ts keeps the conversation threaded instead of flooding the main channel — important for anything beyond a one-off question. Full request/response details are in /docs/messages.

Keep context per thread

Without conversation history, every message to the bot is a fresh, context-free prompt. Slack threads give you a natural key for storing history: use thread_ts (or ts if it's the first message) as an ID, and persist the message list in Redis, a database, or even an in-memory map for a low-traffic internal bot.

const threads = new Map(); // swap for Redis in production

function getHistory(threadId) {
  return threads.get(threadId) || [];
}

function appendHistory(threadId, role, content) {
  const history = getHistory(threadId);
  history.push({ role, content });
  threads.set(threadId, history.slice(-20)); // cap history length
}

Pass getHistory(threadId) as the messages array in your Claude call instead of a single-turn message, and append both the user message and the assistant reply after each exchange. Capping history length keeps token usage predictable — Slack threads can run for days and you don't want to replay the entire thing on every reply.

Stream responses for long answers

If your bot answers questions that take Claude several seconds to generate, streaming improves perceived responsiveness. Slack doesn't support token-by-token message edits well in practice (rate limits on chat.update kick in fast), so the common pattern is: post a placeholder message, then update it every ~1 second as chunks arrive, rather than on every token.

let buffer = "";
let lastUpdate = Date.now();

const placeholder = await slack.chat.postMessage({
  channel: event.channel,
  thread_ts: event.thread_ts || event.ts,
  text: "Thinking...",
});

for await (const chunk of streamFromClaude(messages)) {
  buffer += chunk;
  if (Date.now() - lastUpdate > 1000) {
    await slack.chat.update({
      channel: event.channel,
      ts: placeholder.ts,
      text: buffer,
    });
    lastUpdate = Date.now();
  }
}

See /docs/streaming for how streaming chunks are formatted. If the bot calls external tools (search, a ticketing system, internal APIs), /docs/tools covers defining tool schemas and handling tool_use responses — useful for a Slack bot that needs to look things up rather than just chat.

Handle rate limits and retries

Slack enforces its own rate limits on chat.postMessage (roughly one per channel per second), separate from whatever limits apply to your Claude calls. Queue outgoing Slack messages per channel rather than firing them as soon as Claude responds, especially if multiple users are mentioning the bot at once. On the Claude side, a 429 should trigger exponential backoff rather than an immediate retry, which just compounds the problem under load.

Deploying it

For a production bot, run this as a long-lived service (not a one-off script) on something like Fly.io, Render, or a small VPS, with the Slack signing secret verified on every incoming request to confirm it's actually Slack calling you. Store the Slack bot token and your Claude API key as environment variables, never in code.

If you're managing the bot for a team and want per-environment keys, usage visibility, or to avoid sharing raw provider credentials across services, /signup gives you a sub_live_... key scoped to this one integration, and /pricing covers the plan tiers if you're running more than one bot or integration off the same account.

Questions

Does the bot need to be always-on, or can it run on a serverless function? It can run serverless (AWS Lambda, Vercel functions, Cloudflare Workers), but you must ack Slack's webhook within 3 seconds — cold starts on serverless platforms sometimes exceed that, causing duplicate event retries. A small always-on container avoids this entirely.

How do I stop the bot from responding to its own messages? Check event.bot_id on incoming events and ignore anything that has one — Slack includes this field for messages posted by bots, including your own.

Can the bot read files or images users share in Slack? Yes, but you need to download the file using Slack's files.info API with your bot token (files aren't publicly accessible by URL), then pass the content to Claude as part of the message — see /docs/messages for the content format Claude expects for non-text inputs.

Turn your Claude access into an HTTPS API

SubToAPI gives you application API keys, streaming, tool use and usage insights on top of your existing Claude access — set up in minutes.

Start free  Read the quickstart →