Build a Slack AI Bot with the Anthropic API
Building a Slack AI bot with the Anthropic API means wiring three things together: a Slack app that listens for mentions or DMs, a backend that forwards those messages to Claude, and a way to post the response back into the right thread. None of this is exotic — Slack's Bolt framework handles the event plumbing, and Claude handles the reasoning. The part that trips people up is less "how do I call the API" and more "how do I keep the bot responsive, manage threads correctly, and not leak API keys into a public workspace."
This guide walks through a working Slack bot end to end: app setup, event handling, calling Claude for a response, giving the bot access to tools (so it can do things, not just talk), and deploying it without exposing raw credentials to every teammate who has access to the codebase.
What you need before you start
- A Slack workspace where you can install custom apps (or ask an admin to do it for you)
- A backend that can receive HTTPS requests — Node.js with Bolt is used in the examples below, but any language works
- An Anthropic API key, or an API key from a proxy like SubToAPI if you want unified billing, streaming, and usage metadata without managing raw Anthropic credentials
- A place to run the bot continuously (a small VPS, a serverless function with a queue, or a container — Slack requires event acknowledgment within 3 seconds, so serverless cold starts need to be handled carefully)
Step 1: Create the Slack app
Go to api.slack.com/apps, create a new app "from scratch," and enable:
- Event Subscriptions — subscribe to
app_mentionandmessage.imso the bot responds to @mentions and direct messages - Bot Token Scopes — add
chat:write,app_mentions:read, andim:history - Socket Mode (optional but recommended for local development) — avoids exposing a public webhook URL while you're building
Install the app to your workspace and grab the bot token (xoxb-...) and signing secret. These go into your backend's environment variables, never into client-side code or a public repo.
Step 2: Handle events with Bolt
Bolt takes care of signature verification and event routing so you're not parsing raw Slack payloads by hand.
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, client }) => {
const reply = await askClaude(event.text, event.thread_ts || event.ts);
await client.chat.postMessage({
channel: event.channel,
thread_ts: event.thread_ts || event.ts,
text: reply,
});
});
app.start();
Threading matters here: always reply in thread_ts so the conversation stays contained instead of flooding the channel with top-level messages.
Step 3: Call the Claude API
The askClaude function is where the actual model call happens. If you're using SubToAPI, the request looks like a standard Messages call, just pointed at SubToAPI's endpoint with your sub_live_... key:
async function askClaude(userText, threadId) {
const res = 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,
system: "You are a helpful Slack assistant. Keep answers concise and use Slack-friendly formatting (bold with *asterisks*, not markdown headers).",
messages: [{ role: "user", content: userText }],
}),
});
const data = await res.json();
return data.content[0].text;
}
Two Slack-specific details are worth baking into the system prompt: Slack doesn't render standard markdown (no ## headers, no bold — it uses single asterisks), and responses should stay short by default, since long walls of text are harder to read in a channel than in a chat UI.
Step 4: Keep conversations coherent across a thread
Slack threads map naturally onto Claude's conversation format, but you need to fetch the thread history and convert it into messages on every call, since Claude doesn't retain state between requests.
async function getThreadHistory(client, channel, threadTs) {
const { messages } = await client.conversations.replies({
channel,
ts: threadTs,
});
return messages.map((m) => ({
role: m.bot_id ? "assistant" : "user",
content: m.text,
}));
}
Pass that array as messages in the request body instead of a single user turn. For long-running threads, trim to the last 15–20 messages to stay within a reasonable context window and keep latency down — full docs on the message format are at /docs/messages.
Step 5: Simulate streaming with message updates
Slack's Events API doesn't support token-by-token streaming into a single message the way a chat UI does, but you can approximate it: post a placeholder message immediately, then update it with chat.update as chunks arrive from a streaming API call. This makes a 5-second response feel instant instead of leaving users staring at nothing.
const placeholder = await client.chat.postMessage({
channel: event.channel,
thread_ts: event.thread_ts || event.ts,
text: "Thinking...",
});
let buffer = "";
// consume a streaming response, updating every ~500ms
await client.chat.update({
channel: event.channel,
ts: placeholder.ts,
text: buffer,
});
Details on setting up streaming requests are in /docs/streaming.
Step 6: Give the bot tools to take action
A Slack bot that only answers questions is useful; one that can look up a ticket, check a deploy status, or query internal data is more useful. Define tools in the request and handle the tool_use response by executing the function server-side, then feeding the result back to Claude for a final answer — the pattern is documented at /docs/tools. This is where most "AI bot" projects graduate from a novelty into something a team actually relies on daily.
Deployment and key management
Keep the Anthropic (or SubToAPI) key on the backend only — never in the Slack app manifest, never in client-side code, and never shared across environments. If multiple people on your team are building against the same account, a dashboard with per-application keys and usage visibility avoids the "who used all the tokens" problem; that's the core of what SubToAPI provides on top of raw API access. Setup takes about the same time as generating a normal API key — see /docs/quickstart for the full flow.
FAQ
Does Slack support real token-by-token streaming from Claude? Not natively in a single message. The common workaround is posting a placeholder message and updating it periodically (chat.update) as the response streams in, which feels close to real-time without violating Slack's rate limits on message edits.
Should I use Socket Mode or a public webhook for events? Socket Mode is easier for development since it avoids exposing a public URL, but production deployments typically move to HTTP event subscriptions behind a proper endpoint for better scaling and observability.
How do I stop the bot from responding to its own messages or other bots? Check event.bot_id before processing — if it's set, the message came from a bot (including your own) and should be ignored to avoid infinite reply loops.