---
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, Agent Sessions, native streaming, scheduled messages, modals, and slash commands.
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).",
  },
  sessionTitle: {
    type: "boolean | ((context) => string | Promise<string | null> | null)",
    default: "true when agentView is enabled",
    description:
      "Automatically title new agent sessions from the root user message, or customize titles with a resolver.",
  },
  suggestedPrompts: {
    type: "SlackSuggestedPrompts",
    description:
      "Static payload or per-thread resolver for prompts pinned when an assistant/agent thread opens.",
  },
  loadingMessages: {
    type: "string[]",
    description:
      "Legacy assistant_view rotating status strings. Agent Sessions use Slack's standard Working state.",
  },
  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

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

```typescript
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](/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.
* 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.

<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 [user history](/docs/history) (which records 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
      - 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.

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

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

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

The `SlackAdapter` exposes:

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

```typescript
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](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(...)` in legacy `assistant_view` 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:
  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 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.
