---
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 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);
}
```

### 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 (e.g., Slack's `mrkdwn`), 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";
```
