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/slackQuick start
The adapter auto-detects SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET from the environment.
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:
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):
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:
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
.clientgetter 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 |
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.
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:
| 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:
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_actionsincludesactions,messageBlocks,messagePromptBlock,messagePromptText,messageTs,triggerId,responseUrl,user, andcontinuationview_submissionincludescallbackId,privateMetadata,values,responseUrls, anduser
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.
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.
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.
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
chatimport - no
@chat-adapter/sharedimport - no
@slack/web-apiimport - no
@slack/socket-modeimport
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:
onAppHomeOpenedis the DM-open signal (Slack no longer signals DM-open viaassistant_thread_startedunderagent_view), and it fires regardless of the opened tab — branch onevent.tab("home"vs"messages") if you also publish a Home view.onAppContextChangedreports 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. AsuggestedPromptsconfig 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, soonSubscribedMessageand per-thread state behave as before. - New sessions are titled from the first line of the root message by default. Set
sessionTitle: falseto 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_changedAgent 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:
| 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:
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:
| 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:
oauth_config:
scopes:
bot:
- assistant:write
settings:
event_subscriptions:
bot_events:
- assistant_thread_started
- assistant_thread_context_changedWhen 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:
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/slackTo 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 (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 32When 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:
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
);
}{
"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
| Feature | Supported |
|---|---|
| Post message | |
| Message replies | |
| Edit message | |
| Delete message | |
| File uploads | |
| Streaming | Native |
| Scheduled messages | Native |
Rich content
| Feature | Supported |
|---|---|
| Card format | Block Kit |
| Buttons | |
| Link buttons | |
| Select menus | |
| Tables | Block Kit |
| Charts | Block Kit |
| Fields | |
| Images in cards | |
| Modals |
Conversations
| Feature | Supported |
|---|---|
| Slash commands | |
| Mentions | |
| Add reactions | |
| Remove reactions | |
| Typing indicator | |
| Mark as read | |
| Message edit events | |
| Message delete events | |
| DMs | |
| Ephemeral messages | Native |
| User lookup | |
| Parent subject | |
| Native client | |
| Custom API endpoint |
Message history
| Feature | Supported |
|---|---|
| Fetch messages | |
| Fetch single message | |
| Fetch thread info | |
| Fetch channel messages | |
| List threads | |
| Fetch channel info | |
| Post channel message |
Resources
- 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 — 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
createFileToolsfactory with approval-gated write tools and a read-only mode. - 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 page.