Building an adapter
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. Anyone can build an adapter for another platform with the same Adapter interface and publish it as a community or 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, or follow the vendor-official guide 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
Key points:
- The package is ESM-only (
"type": "module"). chatis a peer dependency because your adapter runs inside the consumer'sChatinstance.@chat-adapter/sharedprovides error classes and utility functions.
tsup.config.ts
tsconfig.json
vitest.config.ts
Define your types
Start by defining the platform-specific types your adapter needs.
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.
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.
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.
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.
Webhook handling
handleWebhook is the entry point for all incoming platform events. Always:
- Verify the request signature first, and return 401 if it's invalid.
- Parse the platform payload.
- Call
this.chat.processMessage()with positional args. It handleswaitUntilinternally. - 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.
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.
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.
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:
- On the send side, when you render a
ButtonElementto your platform's button payload, encode bothbutton.id(the action ID) andbutton.value(which may beundefined, 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. - On the receive side, when the user clicks a button, decode the platform payload back into
actionIdandvalue, and pass them tochat.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 youronActionhandlers.
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:
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.
Fetching and typing
fetchMessages should return messages in chronological order (oldest first). The nextCursor enables pagination.
Formatting
Delegate to your format converter (covered in the next section).
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.
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.
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:
Then export both the class and factory from your entry point:
Shared utilities
Use the utilities in @chat-adapter/shared instead of reimplementing them:
Error classes
Throw these errors from your adapter methods. The SDK catches and logs them with appropriate context.
Message utilities
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:
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.
Read more
Testing adapters
Write unit tests, integration tests, and replay tests for community Chat SDK adapters.
Publishing your adapter
Package, version, and publish your community Chat SDK adapter to npm.
List a vendor-official adapter
Qualify for the vendor-official tier and add your adapter to the Chat SDK docs, @chat-adapter/catalog, create-chat-sdk, and eve.dev/integrations.
Cards
Send rich interactive cards with buttons, fields, and images across all platforms.