How to Build a Claude-Powered Slack Bot
Building a Claude-powered Slack bot means connecting Slack's Events API to Claude's messages endpoint so that when someone mentions your bot or sends it a DM, Claude generates the reply. The core pieces are a Slack app with the right scopes, a small server (Node, Python, or any HTTP-capable runtime) that receives Slack events, and an HTTPS endpoint that calls Claude and posts the response back. None of this requires a complex framework — a webhook handler and a fetch call get you most of the way there.
This guide walks through the full setup: creating the Slack app, wiring up event subscriptions, calling Claude for a response, and handling threading, rate limits, and streaming-style updates so the bot feels responsive instead of laggy.
Step 1: Create the Slack App
- Go to api.slack.com/apps and create a new app "from scratch."
- Under OAuth & Permissions, add these bot scopes:
app_mentions:readchat:writeim:history(if you want DM support)channels:history(optional, for context in channel threads)
- Under Event Subscriptions, enable events and subscribe to:
app_mentionmessage.im
- Install the app to your workspace and copy the Bot User OAuth Token (
xoxb-...).
Slack will require a verified Request URL before events start flowing — you need your server running (even a temporary ngrok tunnel) before this step completes.
Step 2: Set Up the Event Receiver
Here's a minimal Node/Express handler for Slack events:
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, event, challenge } = req.body;
// Slack's URL verification handshake
if (type === "url_verification") {
return res.json({ challenge });
}
res.sendStatus(200); // acknowledge immediately, Slack expects <3s
if (event?.type === "app_mention" || event?.type === "message") {
const userText = event.text.replace(/<@[^>]+>/g, "").trim();
const reply = await askClaude(userText, event.thread_ts || event.ts);
await slack.chat.postMessage({
channel: event.channel,
thread_ts: event.thread_ts || event.ts,
text: reply,
});
}
});
app.listen(3000);
The key detail: acknowledge the Slack event within 3 seconds, then do the Claude call asynchronously. If you wait for Claude before responding to Slack, you'll get retried events and duplicate replies.
Step 3: Call Claude for the Response
You can call Claude directly with an Anthropic API key, or go through SubToAPI if you'd rather turn your existing Claude access into a standard HTTPS API with its own key management, usage tracking, and team seats — useful if multiple people on your team are building bots or internal tools against the same Claude access.
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",
max_tokens: 500,
messages: [
{ role: "user", content: userText }
],
}),
});
const data = await response.json();
return data.content[0].text;
}
Set SUBTOAPI_KEY as an environment variable (never hardcode it). The request/response shape matches Claude's native messages API, so if you already have Claude prompts working elsewhere, they drop in without changes — see /docs/messages for the full schema.
Step 4: Preserve Conversation Context in Threads
Slack threads are the natural unit of conversation for a bot. To make replies context-aware, track messages per thread_ts in a lightweight store (Redis, a database, or even an in-memory map for a prototype) and pass the accumulated history as the messages array on each call:
const threadHistory = new Map();
function appendToThread(threadId, role, content) {
const history = threadHistory.get(threadId) || [];
history.push({ role, content });
threadHistory.set(threadId, history);
return history;
}
Pass threadHistory.get(threadId) as the messages array instead of a single-turn message. Trim old turns once you approach your model's context window, and consider summarizing long threads rather than sending the full history every time — that keeps both latency and token spend predictable.
Step 5: Simulate Streaming with Message Edits
Slack doesn't support token-by-token streaming in a message the way a chat UI does, but you can approximate it. Post a placeholder message immediately, then edit it as content arrives:
const placeholder = await slack.chat.postMessage({
channel: event.channel,
thread_ts: event.ts,
text: "Thinking…",
});
let buffer = "";
for await (const chunk of streamFromClaude(userText)) {
buffer += chunk;
await slack.chat.update({
channel: event.channel,
ts: placeholder.ts,
text: buffer,
});
}
Rate-limit the chat.update calls (every 500ms–1s is plenty) since Slack throttles edit frequency per channel. Full streaming details for the underlying API are in /docs/streaming.
Step 6: Handle Rate Limits and Errors Gracefully
Claude and Slack both enforce rate limits. Wrap the Claude call in a retry with backoff, and fall back to a friendly error message in Slack rather than silence:
try {
const reply = await askClaude(userText, threadId);
await slack.chat.postMessage({ channel: event.channel, text: reply });
} catch (err) {
await slack.chat.postMessage({
channel: event.channel,
text: "Something went wrong generating a response. Try again in a moment.",
});
}
Step 7: Deploy
Any environment that can run a long-lived HTTP server works: a small VPS, a container on Fly.io or Render, or a serverless function with a queue in front to handle the async Claude call. Keep your Slack signing secret verification in place (strip it from this guide for brevity, but don't skip it in production) to prevent spoofed requests from hitting your endpoint.
If you're running the bot as part of a larger internal toolset, provisioning Claude access through /signup gives you a scoped API key per bot or environment, with usage visible per key — handy for knowing which bot is driving token spend. Plans start at €9/month on the Solo tier; see /pricing for team and scale options.
questions
Do I need Anthropic's native API, or can I use a proxy service? Either works. A proxy like SubToAPI gives you the same request/response shape as Claude's native API (see /docs/quickstart) plus per-key usage tracking, which is useful once more than one bot or teammate shares the same Claude access.
Can the bot stream responses token-by-token in Slack? Not natively — Slack messages aren't designed for live token streaming. The common workaround is posting a placeholder message and periodically editing it with chat.update as content arrives.
How do I keep the bot from losing context in long threads? Store messages per thread_ts and pass accumulated history with each Claude call. Trim or summarize older turns once you approach the model's context limit to control both latency and cost.