← Blog

Build a Slack AI Bot with the Anthropic API

2026-09-24 · 5 min read · SubToAPI Team

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

Step 1: Create the Slack app

Go to api.slack.com/apps, create a new app "from scratch," and enable:

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.

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 →