














Learn how to build, package, and publish your own Chat SDK adapter for any messaging platform.
Adapters are the bridge between Chat SDK and a messaging platform. Each adapter handles webhook verification, message parsing, and API calls for one platform so your handler code stays platform-agnostic.
Chat SDK ships with Vercel-maintained adapters for Slack, Teams, Google Chat, Discord, Telegram, GitHub, and Linear. Community developers can build adapters for any other platform using the same Adapter interface.
| Tier | Description | Examples |
|---|---|---|
| Official | Published under @chat-adapter/* by Vercel | Slack, Teams, Discord |
| Vendor official | Built and maintained by the platform company itself | Resend building a Resend adapter |
| Community | Built by third-party developers | Any open-source adapter |
The @chat-adapter/ npm scope is reserved for official adapters. Publish your adapter under your own scope or as an unscoped package.
This guide uses a hypothetical Matrix adapter as a running example. Replace "matrix" with your platform name throughout.
{
"name": "chat-adapter-matrix",
"version": "0.1.0",
"description": "Matrix adapter for Chat SDK",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": ["dist"],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest run --coverage",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"peerDependencies": {
"chat": "^4.0.0"
},
"dependencies": {
"@chat-adapter/shared": "^4.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"chat": "^4.0.0",
"tsup": "^8.3.0",
"typescript": "^5.7.0",
"vitest": "^4.0.0"
},
"publishConfig": {
"access": "public"
},
"keywords": ["chat-sdk", "chat-adapter", "matrix"],
"license": "MIT"
}Key points:
"type": "module")chat is a peer dependency — your adapter runs inside the consumer's Chat instance@chat-adapter/shared provides error classes and utility functionsimport { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
clean: true,
sourcemap: true,
});{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"strictNullChecks": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}import { defineProject } from "vitest/config";
export default defineProject({
test: {
globals: true,
environment: "node",
coverage: {
provider: "v8",
reporter: ["text", "json-summary"],
include: ["src/**/*.ts"],
exclude: ["src/**/*.test.ts"],
},
},
});Start by defining the platform-specific types your adapter needs.
/** Decoded thread ID components for Matrix */
export interface MatrixThreadId {
/** Matrix room ID (e.g., "!abc123:matrix.org") */
roomId: string;
/** Matrix event ID for the thread root (e.g., "$event123") */
eventId?: string;
}
/** Configuration for the Matrix adapter */
export interface MatrixAdapterConfig {
/** Matrix homeserver URL */
homeserverUrl: string;
/** Access token for the bot account */
accessToken: string;
/** Optional bot display name override */
userName?: string;
}Every adapter needs:
{adapter}:{segment1}:{segment2} thread IDCreate your adapter class implementing the Adapter interface from chat. The following sections walk through each group of methods you need to implement.
Start with the class skeleton and constructor:
import {
extractCard,
extractFiles,
toBuffer,
ValidationError,
} from "@chat-adapter/shared";
import type {
Adapter,
AdapterPostableMessage,
ChatInstance,
EmojiValue,
FetchOptions,
FetchResult,
FormattedContent,
Logger,
RawMessage,
ThreadInfo,
WebhookOptions,
} from "chat";
import { ConsoleLogger, Message } from "chat";
import { MatrixFormatConverter } from "./format-converter";
import type { MatrixAdapterConfig, MatrixThreadId } from "./types";
export class MatrixAdapter implements Adapter<MatrixThreadId, unknown> {
readonly name = "matrix";
readonly userName: string;
readonly botUserId?: string;
private chat: ChatInstance | null = null;
private logger: Logger;
private config: MatrixAdapterConfig;
private converter = new MatrixFormatConverter();
constructor(config: MatrixAdapterConfig & { logger?: Logger }) {
this.config = config;
this.userName = config.userName ?? "matrix-bot";
this.logger = config.logger ?? new ConsoleLogger();
}
// Methods shown in sections below...
}The Adapter interface takes two generics: TThreadId (your decoded thread ID shape) and TRawMessage (the platform's raw message type).
The SDK calls initialize once when the Chat instance is created. Use it to store the ChatInstance reference, set up your logger, validate credentials, and fetch bot info.
async initialize(chat: ChatInstance): Promise<void> {
this.chat = chat;
this.logger = chat.getLogger("matrix");
// Validate credentials, fetch bot user info, etc.
// Example: const me = await this.apiCall("/account/whoami");
// this.botUserId = me.user_id;
}The optional disconnect() method is called during chat.shutdown() to clean up resources. Use it to close persistent connections, tear down subscriptions, or release any platform-specific resources.
async disconnect(): Promise<void> {
// Close WebSocket connections, clean up subscriptions, etc.
// Example: await this.matrixClient.stop();
}Adapters that don't hold persistent connections can skip this method entirely.
Thread IDs typically follow the pattern {adapter}:{segment1}:{segment2}, though some adapters use more or fewer segments. The encodeThreadId and decodeThreadId methods must roundtrip consistently. Use base64url encoding for segments that contain special characters.
encodeThreadId(data: MatrixThreadId): string {
const roomSegment = Buffer.from(data.roomId).toString("base64url");
if (data.eventId) {
const eventSegment = Buffer.from(data.eventId).toString("base64url");
return `matrix:${roomSegment}:${eventSegment}`;
}
return `matrix:${roomSegment}`;
}
decodeThreadId(threadId: string): MatrixThreadId {
const parts = threadId.split(":");
if (parts.length < 2 || parts[0] !== "matrix") {
throw new ValidationError(`Invalid Matrix thread ID: ${threadId}`);
}
const roomId = Buffer.from(parts[1], "base64url").toString();
const eventId = parts[2]
? Buffer.from(parts[2], "base64url").toString()
: undefined;
return { roomId, eventId };
}handleWebhook is the entry point for all incoming platform events. Always:
this.chat.processMessage() with positional args — it handles waitUntil internallyasync handleWebhook(
request: Request,
options?: WebhookOptions
): Promise<Response> {
// 1. Verify request signature
const signature = request.headers.get("x-matrix-signature");
if (!signature) {
return new Response("Missing signature", { status: 401 });
}
const body = await request.text();
const isValid = this.verifySignature(body, signature);
if (!isValid) {
return new Response("Invalid signature", { status: 401 });
}
// 2. Parse the webhook payload
const payload = JSON.parse(body);
// 3. Process the message asynchronously
if (this.chat && payload.type === "m.room.message") {
const threadId = this.encodeThreadId({
roomId: payload.room_id,
eventId: payload.thread_root_id,
});
// Use a factory function for lazy async parsing
const isMention = this.checkMention(payload);
const factory = async (): Promise<Message<unknown>> => {
const msg = this.parseMessage(payload);
if (isMention) {
msg.isMention = true;
}
return msg;
};
// processMessage handles waitUntil registration internally
this.chat.processMessage(this, threadId, factory, options);
}
// 4. Return a fast 200 to acknowledge receipt
return new Response("OK", { status: 200 });
}Convert the raw platform message into a normalized Message instance. The author fields use userId and userName, and isBot accepts boolean | "unknown". Include a metadata object with dateSent and edited instead of a top-level createdAt.
parseMessage(raw: unknown): Message<unknown> {
const payload = raw as Record<string, unknown>;
return new Message({
id: payload.event_id as string,
threadId: this.encodeThreadId({
roomId: payload.room_id as string,
eventId: payload.thread_root_id as string | undefined,
}),
text: payload.body as string,
formatted: this.converter.toAst(payload.body as string),
raw,
author: {
userId: payload.sender as string,
userName: payload.sender as string,
fullName: payload.sender_display_name as string ?? "",
isBot: (payload.sender as string).startsWith("@bot"),
isMe: false,
},
metadata: {
dateSent: new Date(payload.origin_server_ts as number),
edited: false,
},
attachments: [],
});
}Use extractCard() and extractFiles() from @chat-adapter/shared to check for rich content. Use extractPostableAttachments() if your adapter maps normalized Attachment objects to platform-native media uploads. Use your format converter's renderPostable() to convert the message to platform format.
async postMessage(
threadId: string,
message: AdapterPostableMessage
): Promise<RawMessage<unknown>> {
const { roomId, eventId } = this.decodeThreadId(threadId);
const card = extractCard(message);
const files = extractFiles(message);
// Upload files if present
for (const file of files) {
const buffer = await toBuffer(file.data);
// Upload to Matrix media repo...
}
// Render text content
const text = card
? this.converter.renderPostable({ card: message.card })
: this.converter.renderPostable(message);
const response = await this.sendMatrixMessage(roomId, text, eventId);
return { raw: response, id: response.event_id };
}
async editMessage(
threadId: string,
messageId: string,
message: AdapterPostableMessage
): Promise<RawMessage<unknown>> {
const { roomId } = this.decodeThreadId(threadId);
const text = this.converter.renderPostable(message);
const response = await this.editMatrixMessage(roomId, messageId, text);
return { raw: response, id: response.event_id };
}
async deleteMessage(threadId: string, messageId: string): Promise<void> {
const { roomId } = this.decodeThreadId(threadId);
await this.redactMatrixEvent(roomId, messageId);
}When the host app passes a Card with <Button callbackUrl={...}>, the SDK rewrites each such button before your adapter sees the postable: the callbackUrl is stored in the state adapter under a short token, and the button's value field is replaced with __cb:<16-hex-chars> (21 characters total). Your adapter does not need to know this happens — just round-trip button.value through your platform's button payload.
What this means in practice:
ButtonElement to your platform's button payload, encode both button.id (the action ID) and button.value (which may be undefined, a user-supplied value, or a callback token). Pick a delimiter that cannot appear in either, and validate the encoded string fits the platform's limit.actionId and value, and pass them to chat.processAction({ actionId, value, ... }). The SDK will detect the __cb: prefix, look up the stored callback URL, POST to it, and pass the original value (if any) to user onAction handlers.Discord's adapter is a good reference — it joins the action ID and value with \n and validates against Discord's 100-character custom_id limit:
const DISCORD_CUSTOM_ID_DELIMITER = "\n";
const DISCORD_CUSTOM_ID_MAX_LENGTH = 100;
export function encodeDiscordCustomId(
actionId: string,
value?: string
): string {
if (value == null || value === "") {
validateLength(actionId);
return actionId;
}
const encoded = `${actionId}${DISCORD_CUSTOM_ID_DELIMITER}${value}`;
validateLength(encoded);
return encoded;
}
export function decodeDiscordCustomId(customId: string): {
actionId: string;
value: string | undefined;
} {
const idx = customId.indexOf(DISCORD_CUSTOM_ID_DELIMITER);
if (idx === -1) {
return { actionId: customId, value: undefined };
}
// Use the FIRST delimiter only — values may legitimately contain "\n".
return {
actionId: customId.slice(0, idx),
value: customId.slice(idx + 1),
};
}Platform button-data limits are the main constraint to plan for. Discord's
custom_id is 100 chars; Telegram's callback_data is 64 bytes. The SDK's
callback token is fixed at 21 chars (__cb: + 16 hex), so the worst-case
payload your encoding must fit is actionId + delimiter + 21. If the
encoded string exceeds the platform limit, throw a ValidationError from
@chat-adapter/shared so the host app fails fast at post time rather than
silently truncating.
Modals with callbackUrl are handled entirely inside the SDK via stored modal context — your adapter does not need any special handling. Just call chat.processModalSubmit(event, contextId, { waitUntil }) from your webhook handler and the SDK will POST to the modal's callbackUrl (using waitUntil if you provide it, so the response is not blocked).
Handle both EmojiValue objects and plain strings. EmojiValue has a name property and toString() method — there is no unicode field.
async addReaction(
threadId: string,
messageId: string,
emoji: EmojiValue | string
): Promise<void> {
const { roomId } = this.decodeThreadId(threadId);
const emojiStr = typeof emoji === "string" ? emoji : emoji.name;
await this.sendReaction(roomId, messageId, emojiStr);
}
async removeReaction(
threadId: string,
messageId: string,
emoji: EmojiValue | string
): Promise<void> {
const { roomId } = this.decodeThreadId(threadId);
const emojiStr = typeof emoji === "string" ? emoji : emoji.name;
await this.removeMatrixReaction(roomId, messageId, emojiStr);
}fetchMessages should return messages in chronological order (oldest first). The nextCursor enables pagination.
async fetchMessages(
threadId: string,
options?: FetchOptions
): Promise<FetchResult<unknown>> {
const { roomId } = this.decodeThreadId(threadId);
// Fetch from platform API with pagination
return { messages: [], nextCursor: undefined };
}
async fetchThread(threadId: string): Promise<ThreadInfo> {
const { roomId } = this.decodeThreadId(threadId);
return {
id: threadId,
title: undefined,
createdAt: new Date(),
};
}
async startTyping(threadId: string): Promise<void> {
const { roomId } = this.decodeThreadId(threadId);
// Send typing notification via platform API
}Delegate to your format converter (covered in the next section).
renderFormatted(content: FormattedContent): string {
return this.converter.fromAst(content.ast);
}Each adapter needs a format converter that translates between the platform's text format and mdast (Markdown AST), the canonical format used by Chat SDK.
import {
BaseFormatConverter,
type Root,
parseMarkdown,
stringifyMarkdown,
text,
strong,
emphasis,
inlineCode,
codeBlock,
link,
paragraph,
root,
} from "chat";
import type { AdapterPostableMessage } from "chat";
export class MatrixFormatConverter extends BaseFormatConverter {
/**
* Convert platform text to mdast AST.
* If your platform uses standard markdown, just use parseMarkdown().
*/
toAst(platformText: string): Root {
// Matrix supports standard markdown, so we can parse directly
return parseMarkdown(platformText);
}
/**
* Convert mdast AST to platform text format.
* Walk the AST and produce platform-specific markup.
*/
fromAst(ast: Root): string {
// Matrix supports standard markdown, so we can stringify directly
return stringifyMarkdown(ast);
}
/**
* Override renderPostable only if your platform needs custom rendering
* (e.g., converting @mentions to platform-specific syntax).
* The base class already handles text/formatted/card fallback logic.
*/
renderPostable(message: AdapterPostableMessage): string {
// Example: convert @mention syntax to Matrix pill format
const rendered = super.renderPostable(message);
return rendered.replace(
/@(\w+)/g,
(_, name) => `<a href="https://matrix.to/#/@${name}:matrix.org">@${name}</a>`
);
}
}For platforms with non-standard formatting, implement custom parsing in toAst() and rendering in fromAst(). See the Discord adapter for an example of handling platform-specific mention syntax.
These methods are not required but extend your adapter's capabilities:
| Method | Purpose |
|---|---|
disconnect() | Clean up connections and resources during shutdown |
openDM(userId) | Open a direct message conversation |
isDM(threadId) | Check if a thread is a DM |
stream(threadId, textStream) | Stream AI responses in real-time |
openModal(triggerId, modal) | Open a modal/dialog form |
postEphemeral(threadId, userId, message) | Post a message visible to one user |
postChannelMessage(channelId, message) | Post a top-level message (not in a thread) |
onThreadSubscribe(threadId) | Hook for platform-specific subscription setup |
fetchChannelInfo(channelId) | Fetch channel metadata |
listThreads(channelId) | List threads in a channel |
fetchMessage(threadId, messageId) | Fetch a single message by ID |
fetchChannelMessages(channelId) | Fetch top-level channel messages |
channelIdFromThreadId(threadId) | Extract channel ID from a thread ID |
scheduleMessage(threadId, message, options) | Schedule a message for future delivery; return a ScheduledMessage with cancel() |
Implement only the methods your platform supports. The SDK gracefully handles missing optional methods.
Export a factory function that creates your adapter with environment variable fallbacks:
import { ConsoleLogger } from "chat";
import type { Logger } from "chat";
import { ValidationError } from "@chat-adapter/shared";
import { MatrixAdapter } from "./adapter";
import type { MatrixAdapterConfig } from "./types";
export function createMatrixAdapter(
config?: Partial<MatrixAdapterConfig> & { logger?: Logger }
): MatrixAdapter {
const homeserverUrl =
config?.homeserverUrl ?? process.env.MATRIX_HOMESERVER_URL;
const accessToken =
config?.accessToken ?? process.env.MATRIX_ACCESS_TOKEN;
if (!homeserverUrl) {
throw new ValidationError(
"Matrix homeserver URL is required. Pass it in config or set MATRIX_HOMESERVER_URL."
);
}
if (!accessToken) {
throw new ValidationError(
"Matrix access token is required. Pass it in config or set MATRIX_ACCESS_TOKEN."
);
}
return new MatrixAdapter({
homeserverUrl,
accessToken,
userName: config?.userName,
logger: config?.logger,
});
}Then export both the class and factory from your entry point:
export { MatrixAdapter } from "./adapter";
export { MatrixFormatConverter } from "./format-converter";
export { createMatrixAdapter } from "./factory";
export type { MatrixAdapterConfig, MatrixThreadId } from "./types";The @chat-adapter/shared package provides utilities you should use instead of reimplementing:
import {
AdapterError, // Base error class
AdapterRateLimitError, // Platform rate limit hit
AuthenticationError, // Invalid credentials
ResourceNotFoundError, // Thread/message not found
PermissionError, // Insufficient permissions
ValidationError, // Invalid input
NetworkError, // HTTP/connection failure
} from "@chat-adapter/shared";Throw these errors from your adapter methods. The SDK catches and logs them with appropriate context.
import {
extractCard, // Extract CardElement from AdapterPostableMessage
extractFiles, // Extract FileUpload[] from AdapterPostableMessage
toBuffer, // Convert FileDataInput to Buffer (async)
toBufferSync, // Convert FileDataInput to Buffer (sync)
cardToFallbackText, // Convert card to plain text
} from "@chat-adapter/shared";If your adapter persists OAuth tokens to a StateAdapter, encrypt them at rest with the shared AES-256-GCM helpers instead of rolling your own:
import {
encryptToken, // Encrypt a string into an EncryptedTokenData envelope
decryptToken, // Decrypt an envelope back to the original string
decodeKey, // Decode a hex-64 or base64-44 32-byte key (throws on wrong length)
isEncryptedTokenData, // Type guard for tolerating legacy plaintext records
type EncryptedTokenData,
} from "@chat-adapter/shared";Accept the key as an optional encryptionKey config field (auto-detected from a *_ENCRYPTION_KEY env var), encrypt on setInstallation(), decrypt on getInstallation(), and use isEncryptedTokenData to keep accepting plaintext records so operators can roll the key in without flushing existing installs. See @chat-adapter/slack and @chat-adapter/linear for reference implementations.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。