plugins/vercel/skills/chat-sdk/SKILL.md
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.
npx skillsauth add openai/plugins chat-sdkInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.0# Core SDK
npm install chat@^4.18.0
# Platform adapters (install only what you need)
npm install @chat-adapter/slack@^4.18.0
npm install @chat-adapter/telegram@^4.18.0
npm install @chat-adapter/teams@^4.18.0
npm install @chat-adapter/discord@^4.18.0
npm install @chat-adapter/gchat@^4.18.0
npm install @chat-adapter/github@^4.18.0
npm install @chat-adapter/linear@^4.18.0
# State adapters (pick one)
npm install @chat-adapter/state-redis@^4.18.0
npm install @chat-adapter/state-ioredis@^4.18.0
npm install @chat-adapter/state-memory@^4.18.0
Field 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.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;
}
dedupeTtlMs: deduplicates repeated webhook deliveries.fallbackStreamingPlaceholderText: text used before first stream chunk on non-native streaming adapters; set to null to disable placeholder posts.class Chat {
openDM(userId: string): Promise<Channel>;
channel(channelId: string): Channel;
}
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.
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>;
}
Reactions are on SentMessage, not Message:
const sent = await thread.post('Done'); await sent.addReaction('thumbsup');
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;
}
onNewMention(handler)onSubscribedMessage(handler)onNewMessage(pattern, handler)onReaction(filter?, handler)onAction(filter?, handler)onModalSubmit(filter?, handler)onModalClose(filter?, handler)onSlashCommand(filter?, handler)onMemberJoinedChannel(handler)bot.onMemberJoinedChannel(async (event) => {
await event.thread.post(`Welcome ${event.user.fullName}!`);
});
onAction, onModalSubmit, onModalClose, and onReaction support:
bot.onAction(async (event) => { ... })bot.onAction("approve", async (event) => { ... })bot.onAction(["approve", "reject"], async (event) => { ... })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;
}
onModalSubmit may return ModalResponse to close, validate, update, or push another modal.
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>,
);
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.
<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>,
);
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.
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_SECRET
import { createTeamsAdapter } from "@chat-adapter/teams";
const teams = createTeamsAdapter({
appType: "singleTenant",
});
// Env: TEAMS_APP_ID, TEAMS_APP_PASSWORD, TEAMS_APP_TENANT_ID
import { createDiscordAdapter } from "@chat-adapter/discord";
const discord = createDiscordAdapter();
// Env: DISCORD_BOT_TOKEN, DISCORD_PUBLIC_KEY, DISCORD_APPLICATION_ID, CRON_SECRET
For message content handlers, enable both Gateway intent and Message Content Intent in the Discord developer portal.
import { createGoogleChatAdapter } from "@chat-adapter/gchat";
const gchat = createGoogleChatAdapter();
// Env: GOOGLE_CHAT_CREDENTIALS, GOOGLE_CHAT_USE_ADC
import { 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_ID
import { 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());
}
openDM() and channel()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}`);
});
registerSingleton() and reviver()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);
}
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.tools
Top-level workflow skill for USD performance diagnosis and optimization. Use for slow loading, high memory, low FPS, or 'optimize my scene' requests; delegates auth/runtime setup to Phase 0 owners.
data-ai
Use when the user mentions MagicPath, designs, UI components, themes, canvas selections, or repo-to-canvas UI work; run magicpath-ai to search, inspect, install, or author components.
documentation
Use as the top-level router for Omniverse Realtime Viewer USD app requests and focused viewer reference documents.
tools
Turn Notion specs into implementation plans, tasks, and progress tracking; use when implementing PRDs/feature specs and creating Notion plans + tasks from them.