Slack

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

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}!`);
}

For Enterprise Grid org-wide installs (is_enterprise_install), Slack returns no team and the installation is keyed by the enterprise ID instead. The returned teamId is always the storage key, so it round-trips with getInstallation and deleteInstallation for both install types; the result also includes enterpriseId and isEnterpriseInstall.

The adapter handles the other Enterprise Grid mechanics automatically: API calls during event handling pass the event's team_id explicitly (required for workspace-scoped methods on org-wide tokens), context_team_id from away-hosted shared channels is echoed back as client_context_team_id, retried event deliveries are deduplicated by event_id, and user caches are scoped per installation.

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!");
  },
  { installationId: teamId }
);

withBotToken uses AsyncLocalStorage, so concurrent calls with different tokens stay isolated. In multi-workspace deployments, pass installationId (the team_id, or enterprise_id for org-wide installs — the key the installation was stored under) so per-user caches are scoped to that installation and don't bleed across tenants.

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.

Low-level APIs

Use the low-level Slack subpaths when your app already owns routing, state, sessions, or workflow execution and only needs the Slack-specific primitives.

SubpathUse for
@chat-adapter/slack/webhookRequest verification, body parsing, Events API payloads, slash commands, interactions, and continuation data
@chat-adapter/slack/formatSlack mrkdwn tokens, text objects, dates, links, mentions, and simple mrkdwn to Markdown conversion
@chat-adapter/slack/apiFetch-based Slack Web API calls, thread replies, views, and files without @slack/web-api
@chat-adapter/slack/blocksRuntime-free conversion from simple card objects and input requests to Slack Block Kit

These subpaths are for custom runtimes. If you want Chat SDK to handle webhook routing, state, subscriptions, and platform normalization, use createSlackAdapter from @chat-adapter/slack.

Webhooks

Slack signs incoming HTTP requests with x-slack-signature and x-slack-request-timestamp. verifySlackRequest reads the request body, verifies the signature with your signing secret, and returns the raw body so you can parse it once.

app/api/slack/route.ts
import {
  parseSlackWebhookBody,
  verifySlackRequest,
} from "@chat-adapter/slack/webhook";
import { postSlackMessage } from "@chat-adapter/slack/api";

export async function POST(request: Request) {
  const body = await verifySlackRequest(request, {
    signingSecret: process.env.SLACK_SIGNING_SECRET!,
  });

  const payload = parseSlackWebhookBody(body, {
    contentType: request.headers.get("content-type"),
    headers: request.headers,
  });

  if (payload.kind === "url_verification") {
    return Response.json({ challenge: payload.challenge });
  }

  if (payload.kind === "app_mention") {
    await postSlackMessage({
      channel: payload.continuation.channelId,
      markdownText: `received: ${payload.text}`,
      threadTs: payload.continuation.threadTs,
      token: process.env.SLACK_BOT_TOKEN!,
    });
  }

  return new Response(null, { status: 200 });
}

Slack slash commands and interactions should be acknowledged quickly. Slack documents a 3000 ms acknowledgement window for slash commands, so do slow work in your queue or workflow runtime after returning a 2xx response.

If you do not need direct access to the verified raw body, readSlackWebhook combines verification and parsing:

import { readSlackWebhook } from "@chat-adapter/slack/webhook";

const payload = await readSlackWebhook(request, {
  signingSecret: process.env.SLACK_SIGNING_SECRET!,
});

If your framework already buffered the request body, use verifySlackSignature with the raw body and headers, then pass that same body to parseSlackWebhookBody.

Payloads

parseSlackWebhookBody returns typed payloads:

KindSlack surface
url_verificationEvents API URL verification
app_mentionApp mention events
direct_messageDirect message events
slash_commandSlash command form posts
block_actionsButton, select, and Block Kit action payloads
block_suggestionExternal select suggestion payloads
view_submissionModal submissions
view_closedModal close events
unsupportedValid Slack payloads not normalized by this helper yet

Message-like payloads include continuation, which contains provider-native reply context:

type SlackContinuation = {
  channelId: string;
  enterpriseId?: string;
  teamId?: string;
  threadTs: string;
};

This is not a Chat SDK Thread. It is the durable Slack data you need to reply later with @chat-adapter/slack/api.

App mention and direct message payloads also include typed files parsed from Slack file objects. Each file keeps the raw Slack object plus common fields like id, name, mimeType, size, url, and downloadUrl.

Interaction payloads expose convenience fields from Slack's raw payload:

  • block_actions includes actions, messageBlocks, messagePromptBlock, messagePromptText, messageTs, triggerId, responseUrl, user, and continuation
  • view_submission includes callbackId, privateMetadata, values, responseUrls, and user

Formatting

Slack uses mrkdwn and special tokens for mentions, channels, dates, and links. The format subpath gives you small helpers for those strings.

The helper surface includes escapeSlackText, unescapeSlackText, createSlackPlainText, createSlackMrkdwn, formatSlackUser, formatSlackChannel, formatSlackUserGroup, formatSlackSpecialMention, formatSlackLink, formatSlackDate, and simple mrkdwn to Markdown normalization.

format.ts
import {
  createSlackMrkdwn,
  formatSlackDate,
  formatSlackLink,
  formatSlackUser,
  slackMrkdwnToMarkdown,
} from "@chat-adapter/slack/format";

const text = createSlackMrkdwn(
  `${formatSlackUser("U123")} approved ${formatSlackLink("https://example.com", "the deploy")}`
);

const when = formatSlackDate(
  new Date("2026-05-27T12:00:00Z"),
  "{date_short_pretty} at {time}",
  "May 27 at 12:00"
);

const markdown = slackMrkdwnToMarkdown("hello <@U123|jane>, see <https://example.com|this>");

linkBareSlackMentions only links Slack user IDs like @U123. It does not resolve display names, because Slack mentions are ID-based.

Web API

The API subpath calls Slack Web API methods with fetch. It does not import @slack/web-api.

slack.ts
import {
  postSlackMessage,
  sendSlackResponseUrl,
  updateSlackMessage,
} from "@chat-adapter/slack/api";

const posted = await postSlackMessage({
  channel: "C123",
  markdownText: "**hello**",
  token: process.env.SLACK_BOT_TOKEN!,
});

await updateSlackMessage({
  channel: "C123",
  text: "updated",
  token: process.env.SLACK_BOT_TOKEN!,
  ts: posted.id,
});

await sendSlackResponseUrl("https://hooks.slack.com/actions/T/1/abc", {
  replaceOriginal: true,
  text: "done",
});

Use callSlackApi when you need a Slack method that does not have a helper yet:

import { callSlackApi } from "@chat-adapter/slack/api";

const result = await callSlackApi(
  "reactions.add",
  { channel: "C123", name: "white_check_mark", timestamp: "1710000000.000001" },
  { token: process.env.SLACK_BOT_TOKEN! }
);

markdownText maps to the markdown_text field on chat.postMessage and cannot be combined with text or blocks. Use text with blocks when you need fallback text.

The subpath also includes postSlackEphemeral, deleteSlackMessage, resolveSlackBotToken, encodeSlackApiBody, and assertSlackOk.

Use fetchSlackThreadReplies when a custom runtime needs to refresh a thread with conversations.replies:

import { fetchSlackThreadReplies } from "@chat-adapter/slack/api";

const replies = await fetchSlackThreadReplies({
  channel: payload.continuation.channelId,
  limit: 50,
  token: process.env.SLACK_BOT_TOKEN!,
  ts: payload.continuation.threadTs,
});

Use openSlackView to open a modal from an interaction trigger_id:

import { openSlackView } from "@chat-adapter/slack/api";

await openSlackView({
  token: process.env.SLACK_BOT_TOKEN!,
  triggerId: payload.triggerId,
  view: {
    type: "modal",
    title: { type: "plain_text", text: "Answer" },
    blocks: [],
  },
});

Files

Slack's current external upload flow uses files.getUploadURLExternal, then uploads bytes to the returned URL, then calls files.completeUploadExternal.

import { uploadSlackFiles } from "@chat-adapter/slack/api";

await uploadSlackFiles(
  [{ data: new Uint8Array([1, 2, 3]), filename: "report.txt" }],
  {
    channelId: "C123",
    initialComment: "report attached",
    token: process.env.SLACK_BOT_TOKEN!,
  }
);

Use fetchSlackFile for private Slack file URLs that require bearer token authorization.

Blocks

The blocks subpath converts simple card objects into Slack Block Kit without importing the full chat JSX runtime.

It exports cardToSlackBlocks, cardToBlockKit, cardToSlackFallbackText, cardToFallbackText, and convertSlackEmojiPlaceholders.

blocks.ts
import {
  cardToSlackBlocks,
  cardToSlackFallbackText,
} from "@chat-adapter/slack/blocks";
import { postSlackMessage } from "@chat-adapter/slack/api";

const card = {
  children: [
    { content: "deploy v2.4.1?", type: "text" },
    {
      children: [
        { id: "approve", label: "Approve", style: "primary", type: "button" },
        { id: "deny", label: "Deny", style: "danger", type: "button" },
      ],
      type: "actions",
    },
  ],
  title: "Deployment",
  type: "card",
} as const;

await postSlackMessage({
  blocks: cardToSlackBlocks(card),
  channel: "C123",
  text: cardToSlackFallbackText(card),
  token: process.env.SLACK_BOT_TOKEN!,
});

Use the full Chat SDK card JSX when you want cross-platform rendering. Use @chat-adapter/slack/blocks when you are building a Slack-only runtime and want Block Kit output directly.

Card children support the same element types as the cross-platform card model, including table (rendered as a paginated, sortable data table block with optional caption and pageSize) and chart (rendered as a data visualization block):

const report = {
  children: [
    {
      caption: "Quarterly scores",
      headers: ["Name", "Score"],
      pageSize: 10,
      rows: [["Alice", "98"], ["Bob", "87"]],
      type: "table",
    },
    {
      chart: {
        segments: [
          { label: "Web", value: 45 },
          { label: "Mobile", value: 35 },
        ],
        type: "pie",
      },
      title: "Traffic by Platform",
      type: "chart",
    },
  ],
  title: "Usage Report",
  type: "card",
} as const;

Tables and charts that exceed Slack limits (100 data rows / 20 columns / 10,000 characters for tables; 12 segments or series, 20 categories, 20-character labels, 50-character titles, and 2 charts per message for charts) fall back to a text rendering instead of being rejected by the Slack API.

The blocks subpath also includes small input request helpers for Slack-only runtimes:

import {
  inputRequestToSlackBlocks,
  parseSlackInputResponse,
} from "@chat-adapter/slack/blocks";
import { postSlackMessage } from "@chat-adapter/slack/api";

await postSlackMessage({
  blocks: inputRequestToSlackBlocks({
    options: [
      { id: "approve", label: "Approve", style: "primary" },
      { id: "deny", label: "Deny", style: "danger" },
    ],
    prompt: "Approve deploy?",
    requestId: "deploy-1",
  }),
  channel: "C123",
  text: "Approve deploy?",
  token: process.env.SLACK_BOT_TOKEN!,
});

if (payload.kind === "block_actions") {
  const action = payload.actions[0];
  const response = action ? parseSlackInputResponse(action) : null;
}

Set display: "radio" for radio buttons, or display: "select" for a static select menu. Set allowFreeform: true to add a "Type your answer" button next to the provided options.

For freeform answers, use buildSlackFreeformView with openSlackView, then read the submitted value from payload.values with parseSlackFreeformValue.

Import boundaries

The low-level Slack subpaths are designed to avoid the full runtime import graph:

  • no chat import
  • no @chat-adapter/shared import
  • no @slack/web-api import
  • no @slack/socket-mode import

The package still installs the full Slack adapter dependencies. The subpaths keep your source and bundle imports clean, but they are not a package-size split.

Advanced

Inbound attachments

Incoming file attachments expose a lazy fetchData(). Downloads go through a guarded fetcher that refuses private and internal addresses (including after redirects), limits responses to 25 MB, and times out after 30 seconds. The bot token is sent only to trusted Slack origins and never follows a redirect to another host. Override createFileTransport() in a subclass to route downloads through a proxy.

Agents

Everything for building an AI agent on Slack: the Agent messaging experience (agent_view), Agent Sessions, suggested prompts, native streaming, and feedback buttons.

Agent messaging experience

Slack's Agent messaging experience (agent_view manifest mode) supersedes the older assistant_view. New Slack apps can only use agent_view. Enable it on the adapter:

const slack = createSlackAdapter({ agentView: true });

Slack deprecated assistant_view on August 20, 2026 and will retire it in February 2027. Chat SDK keeps the legacy path available when agentView is false, but new and migrated apps should use Agent messaging now.

With agentView: true:

  • onAppHomeOpened is the DM-open signal (Slack no longer signals DM-open via assistant_thread_started under agent_view), and it fires regardless of the opened tab — branch on event.tab ("home" vs "messages") if you also publish a Home view.
  • onAppContextChanged reports the user's active view (see Handling active-view context).
  • getAppContext(message) returns the folded active-view context on a DM message.
  • setSuggestedPrompts(channelId, undefined, prompts) may omit the thread reference — prompts sit at the top of the agent conversation. A suggestedPrompts config entry is applied automatically on every Messages-tab open.
  • DM messages are threaded per Slack's model (each user message is a thread root). Threads returned by openDM() keep working: when the conversation-scoped thread is subscribed, incoming top-level DM messages route to it, so onSubscribedMessage and per-thread state behave as before.
  • New sessions are titled from the first line of the root message by default. Set sessionTitle: false to disable this, or pass a resolver to customize it.

Because bot replies are threaded under each user message, channel-level history (channel.messages, conversations.history) only returns the user's side of a DM conversation. If you build AI conversation history for DMs, use user history (which records both roles across thread IDs) instead of channel history — otherwise the model never sees its own previous replies.

Add the event subscription and scope to your manifest:

oauth_config:
  scopes:
    bot:
      - assistant:write
      - chat:write

settings:
  event_subscriptions:
    bot_events:
      - app_home_opened
      - app_context_changed
      - agent_session_stopped
      - agent_session_title_changed

Agent Sessions API

With agentView: true, startTyping() transitions the session to processing. Slack shows its standard Working indicator and a native stop button. Chat SDK returns the session to active after posts and streams; use setSessionStatus() directly for suspended or closed states.

await thread.startTyping();

const result = await agent.stream({
  prompt: message.text,
  abortSignal: thread.signal,
});
await thread.post(result.fullStream);

Always pass thread.signal to model APIs. When the user clicks Slack's stop button, Chat SDK aborts that signal locally and through the configured shared state adapter, stops rendering the stream, and transitions the session out of processing.

bot.onAgentSessionStopped(async (event) => {
  await releaseExternalResources(event.threadId);
});

bot.onAgentSessionTitleChanged(async (event) => {
  await syncTitle(event.threadId, event.title);
});

The SlackAdapter exposes:

MethodDescription
setSessionStatus(channelId, threadTs, status)Set processing, active, suspended, or closed
setAssistantTitle(channelId, threadTs, title)Rename the agent session
setSuggestedPrompts(channelId, threadTs, prompts, title?)Show prompt suggestions
publishHomeView(userId, view)Publish a Home tab view
startTyping(threadId)Set the agent session to processing

setAssistantStatus and setAssistantTitle remain compatibility methods: under agentView, they map to agents.sessions.*; under legacy assistant_view, they call assistant.threads.*. Custom status text and loadingMessages only apply to the legacy experience.

Customize automatic titles with sessionTitle:

const slack = createSlackAdapter({
  agentView: true,
  sessionTitle: ({ text }) => text.split("\n", 1)[0]?.slice(0, 80) ?? null,
});

Legacy 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
});

Instead of wiring the handler yourself, you can configure prompts declaratively — the adapter applies them automatically whenever an assistant/agent thread opens (assistant_thread_started in legacy mode, or a Messages-tab open with agentView enabled):

const slack = createSlackAdapter({
  suggestedPrompts: {
    title: "Welcome! What can I do for you?",
    prompts: [
      { title: "Summarize", message: "Summarize this channel" },
      { title: "Draft", message: "Help me draft a message" },
    ],
  },
  // Rotating status strings shown while the bot is thinking
  loadingMessages: ["Thinking...", "Digging through the archives..."],
});

suggestedPrompts also accepts an async resolver, called per thread-open with the thread context (channelId, userId, threadTs in legacy mode, active-view entities under agentView). Return null to skip a thread. Slack shows at most 4 prompts.

const slack = createSlackAdapter({
  agentView: true,
  suggestedPrompts: async ({ userId, entities }) => ({
    prompts: entities?.some((e) => e.kind === "channel")
      ? [{ title: "Summarize", message: "Summarize the channel I'm viewing" }]
      : [{ title: "Catch me up", message: "What did I miss today?" }],
  }),
});

loadingMessages becomes the default for startTyping(threadId) and setAssistantStatus(...) in legacy assistant_view when no explicit status/messages are passed.

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" },
        ],
      },
    ],
  })
);

Native streaming

Streamed posts (thread.post(asyncIterable)) use Slack's native streaming API (chat.startStream / chat.appendStream / chat.stopStream) whenever the thread has streaming context: any DM thread, or a channel thread where the recipient user/team is known (derived automatically from the incoming message). Structured task_update / plan_update chunks render as native task cards, and plain text renders token-by-token with safe incremental markdown.

Threads without streaming context fall back to post-and-edit (chat.update deltas) automatically. If the workspace rejects the first native call — for example on Slack flavours without the streaming methods, like GovSlack — the adapter falls back to post-and-edit mid-stream without losing content, and skips the native attempt on subsequent streams when the error is permanent (e.g. unknown_method). To skip native streaming entirely:

const slack = createSlackAdapter({ nativeStreaming: false });

Feedback buttons

Slack's agent UX guidance recommends native thumbs up/down feedback on agent replies (a context_actions block with a feedback_buttons element). Configure feedbackButtons and the adapter appends them to every streamed reply when the stream finishes:

const slack = createSlackAdapter({
  feedbackButtons: true, // or customize:
  // feedbackButtons: {
  //   actionId: "ai_feedback",
  //   positiveLabel: "Helpful", positiveValue: "up",
  //   negativeLabel: "Not helpful", negativeValue: "down",
  // },
});

bot.onAction("message_feedback", async (event) => {
  await recordFeedback(event.threadId, event.messageId, event.value); // "positive" | "negative"
});

Clicks dispatch through the regular action flow with the configured actionId (default "message_feedback"). For non-streamed messages, build the same block with the exported buildFeedbackButtonsBlock(options?) helper and attach it via raw blocks. Feedback buttons are skipped when a stream falls back to post-and-edit.

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:
  agent_view:
    agent_description: A bot built with Chat SDK
  bot_user:
    display_name: My Bot
    always_online: true

oauth_config:
  scopes:
    bot:
      - app_mentions:read
      - assistant:write
      - 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
      - app_home_opened
      - app_context_changed
      - agent_session_stopped
      - agent_session_title_changed
  interactivity:
    is_enabled: true
    request_url: https://your-domain.com/api/webhooks/slack

To expose sender email addresses on incoming messages (message.author.email), also add the users:read.email scope. Without it the field is undefined.

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.

Vercel Connect

Use Vercel Connect to source the Slack bot token at runtime instead of storing one. The connectSlackAdapter() helper from @vercel/connect/chat wires both a botToken resolver and a webhookVerifier for Connect trigger-forwarded webhooks:

import { createSlackAdapter } from "@chat-adapter/slack";
import { connectSlackAdapter } from "@vercel/connect/chat";

createSlackAdapter({
  ...connectSlackAdapter("slack/acme-slack"),
});

This is equivalent to passing a botToken resolver that calls getToken and a webhookVerifier that validates the Vercel OIDC token Connect attaches. Omit signingSecret / SLACK_SIGNING_SECRET when using it.

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 works with both single-workspace tokens and multi-workspace OAuth: events arriving over the socket (or forwarded from a socket listener) resolve per-installation tokens by team_id — or enterprise_id for Enterprise Grid org-wide installs — the same way the webhook path does.

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).

Tables and charts

Card Table elements render as Slack data table blocks — paginated and sortable, with optional caption and pageSize props. Tables that exceed Slack's limits (100 data rows, 20 columns, 10,000 characters across all cells) fall back to ASCII text, and header-only tables render as a plain table block.

Card Chart elements render as Slack data visualization blocks with pie, bar, area, and line chart support:

await thread.post(
  <Card title="Usage report">
    <Chart
      title="Daily Active Users"
      chart={{
        type: "line",
        categories: ["Mon", "Tue", "Wed"],
        series: [
          {
            name: "Web",
            data: [
              { label: "Mon", value: 120 },
              { label: "Tue", value: 135 },
              { label: "Wed", value: 128 },
            ],
          },
        ],
      }}
    />
  </Card>
);

Charts that violate Slack's constraints (50-character title, 12 segments/series, 20 categories, 20-character labels, one data point per category, at most 2 charts per message) fall back to a text rendering of the data instead of being rejected by the Slack API.

Feature support

Messaging

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

Rich content

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

Conversations

FeatureSupported
Slash commands
Mentions
Add reactions
Remove reactions
Typing indicator
Mark as read
Message edit events
Message delete events
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

Resources

See all guides and templates on the resources page.

More adapters