Slack

Build bots for Slack workspaces with full support for threads, reactions, native streaming, scheduled messages, modals, slash commands, and the Assistants API.

Install

pnpm add @chat-adapter/slack

Quick start

The adapter auto-detects SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET from the environment.

lib/bot.ts
import { Chat } from "chat";
import { createSlackAdapter } from "@chat-adapter/slack";

const bot = new Chat({
  userName: "mybot",
  adapters: {
    slack: createSlackAdapter(),
  },
});

bot.onNewMention(async (thread, message) => {
  await thread.post("Hello from Slack!");
});

Configuration

Prop

Type

signingSecret is required for webhook mode (or supply a webhookVerifier). appToken is required for socket mode.

Authentication

Single-workspace mode

Auto-detects SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET:

lib/bot.ts
const bot = new Chat({
  userName: "mybot",
  adapters: {
    slack: createSlackAdapter(),
  },
});

Multi-workspace OAuth

For apps installed across multiple Slack workspaces, omit botToken and provide OAuth credentials. The adapter resolves tokens dynamically from your state adapter using the team_id (or enterprise_id for Enterprise Grid org-wide installs):

lib/bot.ts
import { createSlackAdapter } from "@chat-adapter/slack";
import { createRedisState } from "@chat-adapter/state-redis";

const slackAdapter = createSlackAdapter({
  clientId: process.env.SLACK_CLIENT_ID!,
  clientSecret: process.env.SLACK_CLIENT_SECRET!,
});

const bot = new Chat({
  userName: "mybot",
  adapters: { slack: slackAdapter },
  state: createRedisState(),
});

When you pass any auth-related config (like clientId), the adapter won't fall back to env vars for other auth fields, preventing accidental mixing of auth modes.

OAuth callback

Point your Slack OAuth redirect URL to a route that calls handleOAuthCallback:

app/api/slack/oauth/route.ts
import { slackAdapter } from "@/lib/bot";

export async function GET(request: Request) {
  const { teamId } = await slackAdapter.handleOAuthCallback(request, {
    redirectUri: process.env.SLACK_REDIRECT_URI,
  });
  return new Response(`Installed for team ${teamId}!`);
}

Using the adapter outside webhooks

During webhook handling, the adapter resolves tokens automatically. Outside that context (cron jobs, background workers), use getInstallation and withBotToken:

const install = await slackAdapter.getInstallation(teamId);
if (!install) throw new Error("Workspace not installed");

await slackAdapter.withBotToken(install.botToken, async () => {
  const thread = bot.thread("slack:C12345:1234567890.123456");
  await thread.post("Hello from a cron job!");
});

withBotToken uses AsyncLocalStorage, so concurrent calls with different tokens stay isolated.

Direct API client

Access the underlying WebClient from @slack/web-api via .webClient:

const slack = bot.getAdapter("slack").webClient;
await slack.pins.add({
  channel: "C123ABC",
  timestamp: "1234567890.123456",
});

Single-workspace mode (with a static botToken or synchronous resolver) returns a client anywhere. Multi-workspace mode requires webhook-handler context, or an explicit withBotToken wrapper — calling .webClient outside either throws.

The previous .client getter still works as a deprecated alias for .webClient.

Advanced

Slack app manifest

Create the app from a manifest at api.slack.com/apps:

manifest.yaml
display_information:
  name: My Bot
  description: A bot built with chat-sdk

features:
  bot_user:
    display_name: My Bot
    always_online: true

oauth_config:
  scopes:
    bot:
      - app_mentions:read
      - channels:history
      - channels:read
      - chat:write
      - groups:history
      - groups:read
      - im:history
      - im:read
      - mpim:history
      - mpim:read
      - reactions:read
      - reactions:write
      - users:read

settings:
  event_subscriptions:
    request_url: https://your-domain.com/api/webhooks/slack
    bot_events:
      - app_mention
      - message.channels
      - message.groups
      - message.im
      - message.mpim
      - member_joined_channel
      - assistant_thread_started
      - assistant_thread_context_changed
  interactivity:
    is_enabled: true
    request_url: https://your-domain.com/api/webhooks/slack

After creating the app, copy:

  • Signing SecretSLACK_SIGNING_SECRET
  • Client IDSLACK_CLIENT_ID (multi-workspace only)
  • Client SecretSLACK_CLIENT_SECRET (multi-workspace only)
  • Bot User OAuth TokenSLACK_BOT_TOKEN (single-workspace only)

Token rotation

botToken accepts a function returning a string or Promise<string> — the resolver is invoked per API call, so it composes with Slack token rotation (12-hour TTL) or lazy fetch from a secret manager:

createSlackAdapter({
  botToken: async () => await secrets.get("slack-bot-token"),
});

If the resolver is expensive, cache inside the resolver itself.

Custom webhook verification

Pass webhookVerifier to replace the built-in HMAC check — useful when verification runs in a proxy or signing layer ahead of your handler:

createSlackAdapter({
  webhookVerifier: async (request, body) => {
    if (!(await myProxy.verify(request))) {
      throw new Error("invalid");
    }
    return true;
  },
});

If both signingSecret and webhookVerifier are set, webhookVerifier wins. When using webhookVerifier, you are responsible for replay/timestamp protection.

Token encryption

Pass a base64-encoded 32-byte key as encryptionKey to encrypt bot tokens at rest using AES-256-GCM:

openssl rand -base64 32

When encryptionKey is set, setInstallation() encrypts the token before storing and getInstallation() decrypts transparently.

External installation provider

For deployments that manage Slack tokens in an external system (e.g. Vercel Connect):

createSlackAdapter({
  clientId: process.env.SLACK_CLIENT_ID!,
  clientSecret: process.env.SLACK_CLIENT_SECRET!,
  installationProvider: {
    getInstallation: async (installationId, isEnterpriseInstall) => {
      return await myTokenStore.lookup(installationId, isEnterpriseInstall);
    },
  },
});

When configured, the provider is read-only — setInstallation, deleteInstallation, and handleOAuthCallback continue to write to the internal state adapter.

Socket mode

For environments behind firewalls that can't expose public HTTP endpoints, use Slack Socket Mode:

const bot = new Chat({
  userName: "mybot",
  adapters: {
    slack: createSlackAdapter({
      mode: "socket",
      appToken: process.env.SLACK_APP_TOKEN!,
      botToken: process.env.SLACK_BOT_TOKEN!,
    }),
  },
});

Socket mode is not compatible with multi-workspace OAuth.

Socket mode on serverless (Vercel)

Socket mode requires a persistent WebSocket. The adapter provides a forwarding mechanism — a cron job starts a transient socket listener that acks events and forwards them as HTTP requests to your existing webhook endpoint:

app/api/slack/socket-mode/route.ts
import { after } from "next/server";
import { bot } from "@/lib/bot";

export const maxDuration = 800;

export async function GET(request: Request) {
  const authHeader = request.headers.get("authorization");
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response("Unauthorized", { status: 401 });
  }

  await bot.initialize();
  const slack = bot.getAdapter("slack");
  const webhookUrl = `https://${process.env.VERCEL_URL}/api/webhooks/slack`;

  return slack.startSocketModeListener(
    { waitUntil: (task: Promise<unknown>) => after(() => task) },
    600_000,
    undefined,
    webhookUrl
  );
}
vercel.json
{
  "crons": [
    { "path": "/api/slack/socket-mode", "schedule": "*/9 * * * *" }
  ]
}

Forwarded events are authenticated using socketForwardingSecret (defaults to SLACK_SOCKET_FORWARDING_SECRET, falling back to appToken).

Slack Assistants API

The adapter supports Slack's Assistants API. Register handlers on the Chat instance:

bot.onAssistantThreadStarted(async (event) => {
  const slack = bot.getAdapter("slack");
  await slack.setSuggestedPrompts(event.channelId, event.threadTs, [
    { title: "Summarize", message: "Summarize this channel" },
    { title: "Draft", message: "Help me draft a message" },
  ]);
});

bot.onAssistantContextChanged(async (event) => {
  // User navigated to a different channel
});

The SlackAdapter exposes:

MethodDescription
setSuggestedPrompts(channelId, threadTs, prompts, title?)Show prompt suggestions in the thread
setAssistantStatus(channelId, threadTs, status)Show a thinking/status indicator
setAssistantTitle(channelId, threadTs, title)Set the thread title (shown in History)
publishHomeView(userId, view)Publish a Home tab view for a user
startTyping(threadId, status)Show a custom loading status (requires assistant:write)

Add these scopes/events to your manifest:

oauth_config:
  scopes:
    bot:
      - assistant:write

settings:
  event_subscriptions:
    bot_events:
      - assistant_thread_started
      - assistant_thread_context_changed

When streaming in an assistant thread, attach Block Kit elements to the final message via StreamingPlan's endWith option:

import { StreamingPlan } from "chat";

await thread.post(
  new StreamingPlan(textStream, {
    endWith: [
      {
        type: "actions",
        elements: [
          { type: "button", text: { type: "plain_text", text: "Retry" }, action_id: "retry" },
        ],
      },
    ],
  })
);

Feature support

Messaging

FeatureSupported
Post message
Edit message
Delete message
File uploads
StreamingNative
Scheduled messagesNative

Rich content

FeatureSupported
Card formatBlock Kit
Buttons
Link buttons
Select menus
TablesBlock Kit
Fields
Images in cards
Modals

Conversations

FeatureSupported
Slash commands
Mentions
Add reactions
Remove reactions
Typing indicator
DMs
Ephemeral messagesNative
User lookup
Parent subject
Native client
Custom API endpoint

Message history

FeatureSupported
Fetch messages
Fetch single message
Fetch thread info
Fetch channel messages
List threads
Fetch channel info
Post channel message