npx skills add ...
npx skills add openai/plugins --skill chat-sdk
npx skills add openai/plugins --skill chat-sdk
Vercel Chat SDK expert guidance. Use when building multi-platform chat bots — Slack, Telegram, Microsoft Teams, Discord, Google Chat, GitHub, Linear — with a single codebase. Covers the Chat class, adapters, threads, messages, cards, modals, streaming, state management, and webhook setup.
CRITICAL — Your training data is outdated for this library. Chat SDK is new (v4.18+) and not in most training data. Before writing Chat SDK code, fetch the docs at https://chat-sdk.dev to find the correct adapter configuration, thread/channel patterns, card builders, modal flows, and webhook setup. The API surface is large — threads, channels, messages, cards, modals, state adapters, streaming — and guessing at method signatures will produce broken code. Check the GitHub repo at https://github.com/vercel/chat for working examples.
You are an expert in the Vercel Chat SDK. Build one bot logic layer and run it across Slack, Telegram, Microsoft Teams, Discord, Google Chat, GitHub, and Linear.
chat@^4.18.0@chat-adapter/slack@^4.18.0@chat-adapter/telegram@^4.18.0@chat-adapter/teams@^4.18.0@chat-adapter/discord@^4.18.0@chat-adapter/gchat@^4.18.0@chat-adapter/github@^4.18.0@chat-adapter/linear@^4.18.0@chat-adapter/state-redis@^4.18.0@chat-adapter/state-ioredis@^4.18.0@chat-adapter/state-memory@^4.18.0Field takes an options array of { label, value } objects. Do not pass JSX child options.Thread<TState> / Channel<TState> generics require object state shapes (Record<string, unknown>), not primitives.signingSecret validation can run at import/adapter creation time. Use lazy initialization to avoid crashing at module import.dedupeTtlMs: deduplicates repeated webhook deliveries.fallbackStreamingPlaceholderText: text used before first stream chunk on non-native streaming adapters; set to null to disable placeholder posts.openDM() opens or resolves a direct message channel outside the current thread context.channel() gets a channel handle for out-of-thread posting.Thread and Channel share the same Postable interface.
Reactions are on SentMessage, not Message:
const sent = await thread.post('Done'); await sent.addReaction('thumbsup');
onNewMention(handler)onSubscribedMessage(handler)onNewMessage(pattern, handler)onReaction(filter?, handler)onAction(filter?, handler)onModalSubmit(filter?, handler)onModalClose(filter?, handler)onSlashCommand(filter?, handler)onMemberJoinedChannel(handler)onAction, onModalSubmit, onModalClose, and onReaction support:
bot.onAction(async (event) => { ... })bot.onAction("approve", async (event) => { ... })bot.onAction(["approve", "reject"], async (event) => { ... })onModalSubmit may return ModalResponse to close, validate, update, or push another modal.
Card additions to use when needed:
Card.subtitleCard.imageUrlCardLinkField (options uses { label, value }[], not JSX children)Table / TableRow / TableCell — native per-platform table rendering (new — Mar 6, 2026; see below)Text styles (default, muted, success, warning, danger, code)The Table component renders natively on each platform:
| Platform | Rendering |
|---|---|
| Slack | Block Kit table blocks |
| Teams / Discord | GFM markdown tables |
| Google Chat | Monospace text widgets |
| Telegram | Code blocks |
| GitHub / Linear | Markdown tables (existing pipeline) |
Plain markdown tables (without Table()) also pass through the same adapter conversion pipeline.
Use privateMetadata to pass contextual data into submit/close events.
Chat SDK payloads render natively in chat platforms, so shadcn isn't used in message markup. But when building a web control plane, thread inspector, or bot settings UI around Chat SDK, use shadcn + Geist by default. Thread dashboards: Tabs+Card+Table+Badge. Bot settings: Sheet+form controls. Logs/IDs/timestamps: Geist Mono with tabular-nums.
For message content handlers, enable both Gateway intent and Message Content Intent in the Discord developer portal.
openDM() and channel()registerSingleton() and reviver()onNewMention only fires for unsubscribed threads; call thread.subscribe() to receive follow-ups.message.isMention = true.onNewMessage(pattern, handler) only applies before subscription; use onSubscribedMessage after subscribe.openDM() / channel() needs platform permissions for DM/channel posting.**bold** syntax during streaming.fallbackStreamingPlaceholderText: null disables placeholder messages on fallback adapters.streamingUpdateIntervalMs too low can trigger rate limits on post+edit adapters.dedupeTtlMs should cover webhook retry windows to avoid duplicate responses.startTyping() is adapter-dependent and may no-op on platforms without typing indicators.GOOGLE_CHAT_CREDENTIALS + GOOGLE_CHAT_USE_ADC; domain-wide delegation/impersonation is required for some org posting scenarios.appType plus TEAMS_APP_TENANT_ID; reactions/history/typing features are limited compared with Slack.GITHUB_INSTALLATION_ID and often adapter botUserId; Linear OAuth setups need clientId, clientSecret, and LINEAR_ACCESS_TOKEN.import { createSlackAdapter } from "@chat-adapter/slack";
let slackAdapter: ReturnType<typeof createSlackAdapter> | undefined;
export function getSlackAdapter() {
if (!slackAdapter) {
slackAdapter = createSlackAdapter({
signingSecret: process.env.SLACK_SIGNING_SECRET!,
});
}
return slackAdapter;
}import { Chat } from "chat";
import { createSlackAdapter } from "@chat-adapter/slack";
import { createTelegramAdapter } from "@chat-adapter/telegram";
import { createRedisState } from "@chat-adapter/state-redis";
const bot = new Chat({
userName: "my-bot",
adapters: {
slack: createSlackAdapter(),
telegram: createTelegramAdapter(),
},
state: createRedisState(),
streamingUpdateIntervalMs: 1000,
dedupeTtlMs: 10_000,
fallbackStreamingPlaceholderText: "Thinking...",
});
bot.onNewMention(async (thread, message) => {
await thread.subscribe();
await thread.post(`Received: ${message.text}`);
});
bot.onSubscribedMessage(async (thread, message) => {
await thread.post(`Echo: ${message.text}`);
});interface ChatConfig<TAdapters> {
userName: string;
adapters: TAdapters;
state: StateAdapter;
logger?: Logger | LogLevel;
streamingUpdateIntervalMs?: number;
dedupeTtlMs?: number;
fallbackStreamingPlaceholderText?: string | null;
}class Chat {
openDM(userId: string): Promise<Channel>;
channel(channelId: string): Channel;
}interface Postable<TState extends Record<string, unknown> = Record<string, unknown>> {
post(content: PostableContent): Promise<SentMessage>;
postEphemeral(
userId: string,
content: PostableContent,
): Promise<SentMessage | null>;
mentionUser(userId: string): string;
startTyping(): Promise<void>;
messages: AsyncIterable<Message>;
state: Promise<TState | null>;
setState(
partial: Partial<TState>,
opts?: { replace?: boolean },
): Promise<void>;
}interface Thread<TState extends Record<string, unknown> = Record<string, unknown>> extends Postable<TState> {
id: string;
channelId: string;
subscribe(): Promise<void>;
unsubscribe(): Promise<void>;
isSubscribed(): Promise<boolean>;
refresh(): Promise<void>;
createSentMessageFromMessage(message: Message): SentMessage;
}class Message<TRaw = unknown> {
id: string;
threadId: string;
text: string;
isMention: boolean;
raw: TRaw;
toJSON(): SerializedMessage;
static fromJSON(data: SerializedMessage): Message;
}interface SentMessage extends Message {
edit(content: PostableContent): Promise<void>;
delete(): Promise<void>;
addReaction(emoji: string): Promise<void>;
removeReaction(emoji: string): Promise<void>;
}const sent = await thread.post("Done");
await sent.addReaction("thumbsup");interface Channel<TState extends Record<string, unknown> = Record<string, unknown>> extends Postable<TState> {
id: string;
name?: string;
}bot.onMemberJoinedChannel(async (event) => {
await event.thread.post(`Welcome ${event.user.fullName}!`);
});interface ActionEvent {
actionId: string;
value?: string;
triggerId?: string;
privateMetadata?: string;
thread: Thread;
relatedThread?: Thread;
relatedMessage?: Message;
openModal: (modal: JSX.Element) => Promise<void>;
}
interface ModalEvent {
callbackId: string;
values: Record<string, string>;
triggerId?: string;
privateMetadata?: string;
relatedThread?: Thread;
relatedMessage?: Message;
}await thread.post(
<Card
title="Build Status"
subtitle="Production"
imageUrl="https://example.com/preview.png"
>
<Text style="success">Deployment succeeded.</Text>
<Text style="muted">Commit: a1b2c3d</Text>
<Field
id="deploy-target"
label="Target"
options={[
{ label: "Staging", value: "staging" },
{ label: "Production", value: "prod" },
]}
value="prod"
/>
<Table>
<TableRow>
<TableCell>Region</TableCell>
<TableCell>us-east-1</TableCell>
</TableRow>
<TableRow>
<TableCell>Latency</TableCell>
<TableCell>128ms</TableCell>
</TableRow>
</Table>
<Actions>
<Button id="rollback" style="danger">
Rollback
</Button>
<CardLink url="https://vercel.com/dashboard">Open Dashboard</CardLink>
</Actions>
</Card>,
);<Table>
<TableRow>
<TableCell>Region</TableCell>
<TableCell>us-east-1</TableCell>
</TableRow>
<TableRow>
<TableCell>Latency</TableCell>
<TableCell>128ms</TableCell>
</TableRow>
</Table>await event.openModal(
<Modal
callbackId="deploy-form"
title="Deploy"
submitLabel="Deploy"
closeLabel="Cancel"
notifyOnClose
privateMetadata={JSON.stringify({ releaseId: "rel_123" })}
>
<TextInput id="reason" label="Reason" multiline />
</Modal>,
);import { createSlackAdapter } from "@chat-adapter/slack";
const slack = createSlackAdapter();
// Env: SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET
const oauthSlack = createSlackAdapter({
clientId: process.env.SLACK_CLIENT_ID!,
clientSecret: process.env.SLACK_CLIENT_SECRET!,
encryptionKey: process.env.SLACK_ENCRYPTION_KEY,
});import { createTelegramAdapter } from "@chat-adapter/telegram";
const telegram = createTelegramAdapter();
// Env: TELEGRAM_BOT_TOKEN, TELEGRAM_WEBHOOK_SECRETimport { createTeamsAdapter } from "@chat-adapter/teams";
const teams = createTeamsAdapter({
appType: "singleTenant",
});
// Env: TEAMS_APP_ID, TEAMS_APP_PASSWORD, TEAMS_APP_TENANT_IDimport { createDiscordAdapter } from "@chat-adapter/discord";
const discord = createDiscordAdapter();
// Env: DISCORD_BOT_TOKEN, DISCORD_PUBLIC_KEY, DISCORD_APPLICATION_ID, CRON_SECRETimport { createGoogleChatAdapter } from "@chat-adapter/gchat";
const gchat = createGoogleChatAdapter();
// Env: GOOGLE_CHAT_CREDENTIALS, GOOGLE_CHAT_USE_ADCimport { createGitHubAdapter } from "@chat-adapter/github";
const github = createGitHubAdapter({
botUserId: process.env.GITHUB_BOT_USER_ID,
});
// Env: GITHUB_TOKEN or (GITHUB_APP_ID + GITHUB_PRIVATE_KEY),
// GITHUB_WEBHOOK_SECRET, GITHUB_INSTALLATION_IDimport { createLinearAdapter } from "@chat-adapter/linear";
const linear = createLinearAdapter({
clientId: process.env.LINEAR_CLIENT_ID,
clientSecret: process.env.LINEAR_CLIENT_SECRET,
accessToken: process.env.LINEAR_ACCESS_TOKEN,
});import { createRedisState } from "@chat-adapter/state-redis";
const state = createRedisState();
// Env: REDIS_URL (or REDIS_HOST/REDIS_PORT/REDIS_PASSWORD)import { createIoRedisState } from "@chat-adapter/state-ioredis";
const state = createIoRedisState({
// cluster/sentinel options
});import { MemoryState } from "@chat-adapter/state-memory";
const state = new MemoryState();// app/api/webhooks/slack/route.ts
import { bot } from "@/lib/bot";
import { after } from "next/server";
export async function POST(req: Request) {
return bot.webhooks.slack(req, {
waitUntil: (p) => after(() => p),
});
}// app/api/webhooks/telegram/route.ts
import { bot } from "@/lib/bot";
export async function POST(req: Request) {
return bot.webhooks.telegram(req);
}// pages/api/bot.ts
export default async function handler(req, res) {
const response = await bot.webhooks.slack(req);
res.status(response.status).send(await response.text());
}bot.onAction("handoff", async (event) => {
const dm = await bot.openDM(event.user.id);
await dm.post("A human will follow up shortly.");
const ops = bot.channel("ops-alerts");
await ops.post(`Escalated by ${event.user.fullName}`);
});bot.registerSingleton();
const serialized = JSON.stringify({ thread });
const revived = JSON.parse(serialized, bot.reviver());
await revived.thread.post("Resumed workflow step");// app/api/webhooks/slack/oauth/callback/route.ts
import { bot } from "@/lib/bot";
export async function GET(req: Request) {
return bot.oauth.slack.callback(req);
}