chat
Version:
Unified chat abstraction for Slack, Teams, Google Chat, and Discord
714 lines (592 loc) • 24.2 kB
text/mdx
---
title: Building a community adapter
description: Learn how to build, package, and publish your own Chat SDK adapter for any messaging platform.
type: guide
prerequisites:
- /docs/getting-started
- /docs/adapters
related:
- /docs/contributing/testing
- /docs/cards
- /docs/actions
---
## What adapters are
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.
### Adapter tiers
| 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 |
<Callout type="warn">
The `@chat-adapter/` npm scope is reserved for official adapters. Publish your adapter under your own scope or as an unscoped package.
</Callout>
#### Qualifications for vendor official tier
- Commitment for continued maintenance of the adapter.
- GitHub hosting in official vendor-owned org.
- Documentation of the adapter in primary vendor docs.
- Announcement of the adapter in blog post or changelog and social media.
## Project setup
This guide uses a hypothetical **Matrix** adapter as a running example. Replace "matrix" with your platform name throughout.
### package.json
```json title="package.json" lineNumbers
{
"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:
- ESM-only (`"type": "module"`)
- `chat` is a **peer dependency** — your adapter runs inside the consumer's Chat instance
- `@chat-adapter/shared` provides error classes and utility functions
### tsup.config.ts
```typescript title="tsup.config.ts" lineNumbers
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
clean: true,
sourcemap: true,
});
```
### tsconfig.json
```json title="tsconfig.json" lineNumbers
{
"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"]
}
```
### vitest.config.ts
```typescript title="vitest.config.ts" lineNumbers
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"],
},
},
});
```
## Define your types
Start by defining the platform-specific types your adapter needs.
```typescript title="src/types.ts" lineNumbers
/** 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:
1. A **thread ID interface** — the decoded components of your `{adapter}:{segment1}:{segment2}` thread ID
2. A **config interface** — credentials and options needed to connect to the platform
## Implement the Adapter interface
Create 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:
```typescript title="src/adapter.ts" lineNumbers
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).
### Initialization
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.
```typescript title="src/adapter.ts"
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;
}
```
### Disconnect
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.
```typescript title="src/adapter.ts"
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 ID encode/decode
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.
```typescript title="src/adapter.ts" lineNumbers
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 };
}
```
### Webhook handling
`handleWebhook` is the entry point for all incoming platform events. Always:
1. Verify the request signature first (return 401 if invalid)
2. Parse the platform payload
3. Call `this.chat.processMessage()` with positional args — it handles `waitUntil` internally
4. Return a fast 200 response immediately
```typescript title="src/adapter.ts" lineNumbers
async 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 });
}
```
### Message parsing
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`.
```typescript title="src/adapter.ts" lineNumbers
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: [],
});
}
```
### Sending messages
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.
```typescript title="src/adapter.ts" lineNumbers
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);
}
```
### Buttons and callback URLs
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:
1. **Send side**: when rendering a `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.
2. **Receive side**: when the user clicks a button, decode the platform payload back into `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:
```typescript title="src/cards.ts (discord)" lineNumbers
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),
};
}
```
<Callout type="info">
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.
</Callout>
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).
### Reactions
Handle both `EmojiValue` objects and plain strings. `EmojiValue` has a `name` property and `toString()` method — there is no `unicode` field.
```typescript title="src/adapter.ts" lineNumbers
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);
}
```
### Fetching and typing
`fetchMessages` should return messages in chronological order (oldest first). The `nextCursor` enables pagination.
```typescript title="src/adapter.ts" lineNumbers
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
}
```
### Formatting
Delegate to your format converter (covered in the next section).
```typescript title="src/adapter.ts"
renderFormatted(content: FormattedContent): string {
return this.converter.fromAst(content.ast);
}
```
## Build a format converter
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.
```typescript title="src/format-converter.ts" lineNumbers
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](https://github.com/vercel/chat/blob/main/packages/adapter-discord/src/markdown.ts) for an example of handling platform-specific mention syntax.
## Optional methods
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.
## Factory function
Export a factory function that creates your adapter with environment variable fallbacks:
```typescript title="src/factory.ts" lineNumbers
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:
```typescript title="src/index.ts" lineNumbers
export { MatrixAdapter } from "./adapter";
export { MatrixFormatConverter } from "./format-converter";
export { createMatrixAdapter } from "./factory";
export type { MatrixAdapterConfig, MatrixThreadId } from "./types";
```
## Shared utilities
The `@chat-adapter/shared` package provides utilities you should use instead of reimplementing:
### Error classes
```typescript
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.
### Message utilities
```typescript
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";
```
### Token encryption
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:
```typescript
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.