---
title: Building an adapter
description: Learn how to build your own Chat SDK adapter for any messaging platform.
docs_index: /llms.txt
lastUpdated: 2026-10-14
type: guide
prerequisites:
  - /docs/getting-started
  - /docs/adapters
related:
  - /docs/contributing/testing
  - /docs/contributing/publishing
  - /docs/contributing/vendor-official
  - /docs/cards
  - /docs/actions
---

> For an index of all documentation, see [/llms.txt](/llms.txt).

Build your adapter as its own npm package. It implements the `Adapter` interface for your platform, converts messages between Chat SDK's Markdown AST and the platform's format, and exports a factory function such as `createMatrixAdapter`.

## What adapters are

An adapter connects Chat SDK to one messaging platform. It handles webhook verification, message parsing, and API calls for that platform so your handler code stays platform-agnostic.

Chat SDK ships with Vercel-maintained [official adapters](/adapters). Anyone can build an adapter for another platform with the same `Adapter` interface and publish it as a [community](/docs/contributing/publishing#listing-on-chat-sdkdev) or [vendor-official](/docs/contributing/vendor-official) adapter.

### Adapter tiers

| Tier            | Description                                  | Examples                   |
| --------------- | -------------------------------------------- | -------------------------- |
| Official        | Published under `@chat-adapter/*` by Vercel  | Slack, Teams, Discord      |
| Vendor-official | Built and maintained by the platform company | Resend, Liveblocks, Photon |
| Community       | Built by third-party developers              | Any open-source adapter    |

This guide covers the implementation. After you publish, [list a community adapter on chat-sdk.dev](/docs/contributing/publishing#listing-on-chat-sdkdev), or follow the [vendor-official guide](/docs/contributing/vendor-official) if you represent the platform vendor.

> The `@chat-adapter/` npm scope is reserved for official adapters. Publish your adapter under your own scope or as an unscoped package.

## Project setup

This guide uses a hypothetical Matrix adapter as a running example. Replace "matrix" with your platform name throughout. `YOUR_PUBLISHED_ADAPTER_PACKAGE` is a placeholder, not an npm package to install. Replace it with a valid package name you own before publishing.

### package.json

```json title="package.json" lineNumbers
{
  "name": "YOUR_PUBLISHED_ADAPTER_PACKAGE",
  "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:

- The package is ESM-only (`"type": "module"`).
- `chat` is a peer dependency because 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 two interfaces:

- A thread ID interface that describes the decoded components of your `{adapter}:{segment1}:{segment2}` thread ID.
- A config interface for the credentials and options needed to connect to the platform.

## Implement the Adapter interface

Create an adapter class that implements the `Adapter` interface from `chat`. Start with the class skeleton and constructor, then add each group of methods below.

```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. `channelIdFromThreadId` returns the channel portion of a thread ID, which the SDK uses for channel-level operations such as `thread.channel`.

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

channelIdFromThreadId(threadId: string): string {
  const [adapter, roomSegment] = threadId.split(":");
  return `${adapter}:${roomSegment}`;
}
```

### Webhook handling

`handleWebhook` is the entry point for all incoming platform events. Always:

1. Verify the request signature first, and return 401 if it's invalid.
2. Parse the platform payload.
3. Call `this.chat.processMessage()` with positional args. It handles `waitUntil` internally.
4. Return a 200 response immediately.

Polling transports should await the promises returned by `processMessage`, `processAction`, `processSlashCommand` and `processReaction` before acknowledging input. These promises reject on handler failure; the default `waitUntil` task still logs and absorbs errors for webhook responses. A transport that owns deduplication and recovery, such as Telegram polling, can pass `{ deduplicate: false }` to `processMessage` to bypass the core message deduplication cache. Leave deduplication enabled for ordinary webhook delivery.

```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`.

Set `author.isMe` only when the message was sent by this adapter runtime and should be ignored by Chat SDK's handler dispatch. Do not map a platform "sent from my account" flag directly to `isMe` unless that account is the bot identity. For user-owned account adapters, keep user-authored messages as `isMe: false` and track message IDs returned by `postMessage`; if the platform echoes one of those IDs through the webhook, mark that echo as `isMe: true` and `isBot: true`.

Set `isMention` only when the adapter can decide it from the platform's own metadata. Chat SDK uses the value as the mention decision: `true` and `false` are final, and only `undefined` makes it fall back to matching `@username` in `text`. Leave the field unset when the adapter has no mention information instead of reporting `false`. The one exception is direct messages: when no `onDirectMessage` handler is registered, Chat SDK marks every DM as a mention regardless of what the adapter reported.

```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 of those buttons before your adapter sees the postable. It stores the `callbackUrl` in the state adapter under a short token and replaces the button's `value` with `__cb:<16-hex-chars>`, which is 21 characters in total. Your adapter doesn't need to handle this case separately. It only has to round-trip `button.value` through your platform's button payload.

In practice, that means two things:

1. On the send side, when you render 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. On the 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 detects the `__cb:` prefix, looks up the stored callback URL, POSTs to it, and passes the original value, if there is one, to your `onAction` handlers.

The Discord adapter is a good reference. It joins the action ID and value with `\n` and validates the result 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, because 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.

The SDK handles modals with `callbackUrl` entirely through stored modal context, so your adapter needs no special handling. Call `chat.processModalSubmit(event, contextId, { waitUntil })` from your webhook handler, and the SDK POSTs to the modal's `callbackUrl`. If you provide `waitUntil`, the SDK uses it so the POST doesn't block the response.

### Reactions

Handle both `EmojiValue` objects and plain strings. `EmojiValue` has a `name` property and a `toString()` method, but 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 optional. Implement them to support more Chat SDK features:

| Method                                        | Purpose                                                                                                      |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `disconnect()`                                | Clean up connections and resources during shutdown                                                           |
| `getConcurrencyScope()`                       | Scope message deduplication, locks, queues, concurrency limits, and cancellation to the authenticated tenant |
| `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                                                                             |
| `scheduleMessage(threadId, message, options)` | Schedule a message for future delivery; return a `ScheduledMessage` with `cancel()`                          |

Implement only the methods your platform supports. The SDK handles adapters that leave optional methods out.

For multi-tenant adapters whose conversation IDs can overlap, implement `getConcurrencyScope(): string | undefined`. Return a stable, non-secret scope from the authenticated installation context. Base it on the credential boundary: if one installation serves several workspaces with the same credentials, use one scope for all of them, since each entry point may report a different workspace for the same conversation. The same scope must be available during message handling and `abortTurn()` calls, including across processes. Never derive it from the message author's workspace or store credentials in it.

Returning `undefined` preserves unscoped keys for single-tenant adapters. If tenant context is required but unavailable, throw rather than silently using unscoped keys. This hook does not change public conversation IDs or scope subscriptions, thread state, or history.

## 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

Use the utilities in `@chat-adapter/shared` instead of reimplementing them:

### 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.

---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)