---
title: Slack
description: Slack adapter with single-workspace and multi-workspace OAuth support.
tagline: Build bots for Slack workspaces with full support for threads, reactions, native streaming, scheduled messages, modals, slash commands, and the Assistants API.
package: @chat-adapter/slack
---

# Slack



## Install

<PackageInstall package="@chat-adapter/slack" />

## Quick start

<Callout type="info">
  The adapter auto-detects `SLACK_BOT_TOKEN` and `SLACK_SIGNING_SECRET` from the environment.
</Callout>

```typescript title="lib/bot.ts" lineNumbers
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

<TypeTable
  type={{
  botToken: {
    type: "string | () => string | Promise<string>",
    description:
      "Bot token (xoxb-...) or a resolver function for rotation / lazy fetch. Auto-detected from SLACK_BOT_TOKEN.",
  },
  signingSecret: {
    type: "string",
    description:
      "Signing secret for webhook verification. Auto-detected from SLACK_SIGNING_SECRET.",
  },
  webhookVerifier: {
    type: "(request, body) => unknown | Promise<unknown>",
    description:
      "Custom verifier used in place of signingSecret. Returning a string substitutes the verified body downstream.",
  },
  mode: {
    type: '"webhook" | "socket"',
    default: '"webhook"',
    description: "Connection mode.",
  },
  appToken: {
    type: "string",
    description:
      "App-level token (xapp-...) for socket mode. Auto-detected from SLACK_APP_TOKEN.",
  },
  clientId: {
    type: "string",
    description:
      "App client ID for multi-workspace OAuth. Auto-detected from SLACK_CLIENT_ID.",
  },
  clientSecret: {
    type: "string",
    description:
      "App client secret for multi-workspace OAuth. Auto-detected from SLACK_CLIENT_SECRET.",
  },
  encryptionKey: {
    type: "string",
    description:
      "AES-256-GCM key for encrypting stored tokens. Auto-detected from SLACK_ENCRYPTION_KEY.",
  },
  installationKeyPrefix: {
    type: "string",
    default: '"slack:installation"',
    description:
      "Prefix for the state key used to store workspace installations. Full key is {prefix}:{teamId}, or {prefix}:{enterpriseId} for org-wide installs.",
  },
  installationProvider: {
    type: "{ getInstallation(installationId, isEnterpriseInstall) }",
    description:
      "External installation lookup. When set, bypasses the internal state adapter for token resolution. Read-only — manage your own writes externally.",
  },
  agentView: {
    type: "boolean",
    default: "false",
    description:
      "Enable the Agent messaging experience (agent_view manifest mode).",
  },
  suggestedPrompts: {
    type: "SlackSuggestedPrompts",
    description:
      "Static payload or per-thread resolver for prompts pinned when an assistant/agent thread opens.",
  },
  loadingMessages: {
    type: "string[]",
    description:
      "Default rotating status strings for the assistant thinking indicator, used by startTyping and setAssistantStatus when no explicit status is passed.",
  },
  nativeStreaming: {
    type: "boolean",
    default: "true",
    description:
      "Use Slack's native streaming API for streamed posts. Set false to always stream via post-and-edit.",
  },
  feedbackButtons: {
    type: "boolean | SlackFeedbackButtonsOptions",
    description:
      "Append native thumbs up/down to every streamed reply; clicks dispatch to bot.onAction.",
  },
  apiUrl: {
    type: "string",
    description:
      "Override the Slack Web API base URL (e.g. for GovSlack or a self-hosted gateway).",
  },
  webClientOptions: {
    type: 'Omit<WebClientOptions, "slackApiUrl">',
    description:
      "Options forwarded to Slack WebClient instances. Supports settings such as retryConfig, per-request timeout, and rejectRateLimitedCalls.",
  },
}}
/>

`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`:

```typescript title="lib/bot.ts" lineNumbers
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):

```typescript title="lib/bot.ts" lineNumbers
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`:

```typescript title="app/api/slack/oauth/route.ts" lineNumbers
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`:

```typescript
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](https://github.com/slackapi/node-slack-sdk/tree/main/packages/web-api) from `@slack/web-api` via `.webClient`:

```typescript
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.

| Subpath                       | Use for                                                                                                      |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `@chat-adapter/slack/webhook` | Request verification, body parsing, Events API payloads, slash commands, interactions, and continuation data |
| `@chat-adapter/slack/format`  | Slack mrkdwn tokens, text objects, dates, links, mentions, and simple mrkdwn to Markdown conversion          |
| `@chat-adapter/slack/api`     | Fetch-based Slack Web API calls, thread replies, views, and files without `@slack/web-api`                   |
| `@chat-adapter/slack/blocks`  | Runtime-free conversion from simple card objects and input requests to Slack Block Kit                       |

<Callout type="info">
  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`.
</Callout>

### Webhooks

[Slack signs incoming HTTP requests](https://docs.slack.dev/authentication/verifying-requests-from-slack/) 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.

```typescript title="app/api/slack/route.ts" lineNumbers
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](https://docs.slack.dev/interactivity/implementing-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:

```typescript
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:

| Kind               | Slack surface                                          |
| ------------------ | ------------------------------------------------------ |
| `url_verification` | Events API URL verification                            |
| `app_mention`      | App mention events                                     |
| `direct_message`   | Direct message events                                  |
| `slash_command`    | Slash command form posts                               |
| `block_actions`    | Button, select, and Block Kit action payloads          |
| `block_suggestion` | External select suggestion payloads                    |
| `view_submission`  | Modal submissions                                      |
| `view_closed`      | Modal close events                                     |
| `unsupported`      | Valid Slack payloads not normalized by this helper yet |

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

```typescript
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.

```typescript title="format.ts" lineNumbers
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](https://docs.slack.dev/apis/web-api/) methods with `fetch`. It does not import `@slack/web-api`.

```typescript title="slack.ts" lineNumbers
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:

```typescript
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`](https://docs.slack.dev/reference/methods/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`](https://docs.slack.dev/reference/methods/conversations.replies/):

```typescript
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`:

```typescript
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](https://docs.slack.dev/changelog/2024-04-a-better-way-to-upload-files-is-here-to-stay) uses `files.getUploadURLExternal`, then uploads bytes to the returned URL, then calls `files.completeUploadExternal`.

```typescript
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`.

```typescript title="blocks.ts" lineNumbers
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](https://docs.slack.dev/reference/block-kit/blocks/data-table-block) with optional `caption` and `pageSize`) and `chart` (rendered as a [data visualization block](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block)):

```typescript
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:

```typescript
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

### Agents

Everything for building an AI agent on Slack: the Agent messaging experience (`agent_view`), the Assistants API (suggested prompts, status, titles), 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:

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

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](/docs/handling-events#handling-active-view-context-agent-messaging)).
* `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.

<Callout type="warn">
  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 [transcripts](/docs/conversation-history) (which record both roles across thread IDs) instead of channel history — otherwise the model never sees its own previous replies.
</Callout>

Add the event subscription and scope to your manifest:

```yaml
oauth_config:
  scopes:
    bot:
      - assistant:write

settings:
  event_subscriptions:
    bot_events:
      - app_home_opened
      - app_context_changed
```

#### Slack Assistants API

The adapter supports Slack's [Assistants API](https://api.slack.com/docs/apps/ai). Register handlers on the `Chat` instance:

```typescript
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`](#agent-messaging-experience) enabled):

```typescript
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.

```typescript
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(...)` when no explicit status/messages are passed.

The `SlackAdapter` exposes:

| Method                                                      | Description                                               |
| ----------------------------------------------------------- | --------------------------------------------------------- |
| `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:

```yaml
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:

```typescript
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:

```typescript
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:

```typescript
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](https://api.slack.com/apps):

```yaml title="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
```

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 Secret** → `SLACK_SIGNING_SECRET`
* **Client ID** → `SLACK_CLIENT_ID` (multi-workspace only)
* **Client Secret** → `SLACK_CLIENT_SECRET` (multi-workspace only)
* **Bot User OAuth Token** → `SLACK_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](https://docs.slack.dev/authentication/using-token-rotation/) (12-hour TTL) or lazy fetch from a secret manager:

```typescript
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:

```typescript
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](https://vercel.com/docs/connect) to source the Slack bot token at runtime instead of storing one. The `connectSlackAdapter()` helper from [`@vercel/connect/chat`](https://www.npmjs.com/package/@vercel/connect) wires both a `botToken` resolver and a `webhookVerifier` for Connect trigger-forwarded webhooks:

```typescript
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:

```bash
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):

```typescript
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](https://api.slack.com/apis/socket-mode):

```typescript
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:

```typescript title="app/api/slack/socket-mode/route.ts" lineNumbers
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
  );
}
```

```json title="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`](/docs/cards#table) elements render as Slack [data table blocks](https://docs.slack.dev/reference/block-kit/blocks/data-table-block) — 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`](/docs/cards#chart) elements render as Slack [data visualization blocks](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block) with pie, bar, area, and line chart support:

```tsx
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

<FeatureSupport />

## Resources

* [How to build an AI agent for Slack with Chat SDK and AI SDK](https://vercel.com/kb/guide/how-to-build-an-ai-agent-for-slack-with-chat-sdk-and-ai-sdk?utm_source=chat-sdk_site\&utm_medium=docs\&utm_campaign=adapter-slack\&utm_content=how-to-build-an-ai-agent-for-slack-with-chat-sdk-and-ai-sdk) — Build a Slack AI agent using Chat SDK, AI SDK's ToolLoopAgent, and Vercel AI Gateway. Covers project setup, tool definitions, streaming responses, deployment to Vercel, and scaling tool selection with toolpick.
* [How to build a Slack bot that manages files in Vercel Blob](https://vercel.com/kb/guide/slack-bot-vercel-blob?utm_source=chat-sdk_site\&utm_medium=docs\&utm_campaign=adapter-slack\&utm_content=slack-bot-vercel-blob) — Build a Slack bot that lists, reads, uploads, and deletes files in Vercel Blob through tool calls. Uses Chat SDK, AI SDK's ToolLoopAgent, and Files SDK's `createFileTools` factory with approval-gated write tools and a read-only mode.
* [How to build a Slack bot with Next.js and Redis](https://vercel.com/kb/guide/how-to-build-a-slack-bot-with-next-js-and-redis?utm_source=chat-sdk_site\&utm_medium=docs\&utm_campaign=adapter-slack\&utm_content=how-to-build-a-slack-bot-with-next-js-and-redis) — Walks through building a Slack bot with Next.js, covering project setup, Slack app configuration, event handling, interactive features, and deployment.

See all guides and templates on the [resources](/resources?utm_source=chat-sdk_site\&utm_medium=docs\&utm_campaign=adapter-slack\&utm_content=resources) page.
