An Intercom adapter for the Chat SDK - connect your own AI agent or bot to Intercom conversations.
Intercom ships Fin, its built-in AI agent. Fin is great, but it isn't always the right fit:
- Bring your own AI - run your own model and logic (e.g. a Mastra agent on Claude, your own RAG pipeline, or rule-based automation) instead of Fin.
- Privacy & data control - keep conversation data and inference on infrastructure and providers you choose.
- Cost - Fin is priced per resolution; a bot you host can be dramatically cheaper at volume.
- Portability - the same Chat SDK bot also runs on Slack, Teams, Discord, WhatsApp, and more. Write your handler once; this adapter makes it work on Intercom.
This package implements the Chat SDK Adapter interface for Intercom: it verifies and ingests Intercom webhooks, normalizes conversations into the SDK's message model, and posts replies back as an admin/teammate via the Intercom REST API (Intercom-Version: 2.15). Your handlers stay platform-agnostic - see examples/mastra-support-bot for a complete support bot powered by a Mastra + Claude agent.
npm install chat-adapter-intercom chatchat is a peer dependency - you bring
your own Chat SDK instance and register this adapter on it.
import { Chat } from "chat";
import {
createIntercomAdapter,
type IntercomAdapter,
} from "chat-adapter-intercom";
const bot = new Chat({
userName: "support-bot",
adapters: { intercom: createIntercomAdapter() },
// Intercom delivers customer messages in quick bursts; "queue" guarantees none
// are dropped while the bot is busy. The adapter already defaults its lock scope
// to "channel" (one Intercom conversation == one channel), so you rarely need to
// set lockScope yourself.
concurrency: "queue",
});
// Intercom has no @-mention. A new conversation (conversation.user.created) is
// mapped to the "mention" entry point, so onNewMention fires once per conversation.
bot.onNewMention(async (thread, message) => {
await thread.subscribe();
await thread.post(`Hi ${message.author.fullName || "there"}! I'm on it.`);
});
// Every subsequent customer reply in a subscribed conversation.
bot.onSubscribedMessage(async (thread, message) => {
await thread.post(`You said: ${message.text}`);
});
// Adapter-specific conversation controls are available via getAdapter().
bot.onAction("resolved", async (event) => {
const intercom = bot.getAdapter("intercom") as IntercomAdapter;
await intercom.closeConversation(event.threadId);
});Webhook route (Next.js App Router):
// app/api/webhooks/intercom/route.ts
export async function POST(req: Request) {
return bot.webhooks.intercom(req);
}Point your Intercom app's webhook at this route and subscribe to the
conversation.user.created and conversation.user.replied topics.
- examples/mastra-support-bot - an Intercom support agent built with Mastra. The adapter plugs straight into a Mastra agent's
channels(channels: { adapters: { intercom: createIntercomAdapter() } }); Mastra auto-generates the webhook and routes messages to the agent. See examples/ for the full list.
createIntercomAdapter(config?) reads from config first, then environment variables.
| Option | Env var | Required | Notes |
|---|---|---|---|
accessToken |
INTERCOM_ACCESS_TOKEN |
β | Developer Hub β App β Authentication |
adminId |
INTERCOM_ADMIN_ID |
β | Numeric ID of the admin the bot posts as (GET /me) |
clientSecret |
INTERCOM_CLIENT_SECRET |
β | Used to verify the X-Hub-Signature webhook HMAC |
region |
INTERCOM_REGION |
- | "us" (default), "eu", or "au" |
userName |
- | - | Bot username (default "intercom-bot") |
Create the app the bot posts as, then point Intercom's webhooks at your route.
- Create an app. In the Intercom Developer Hub, create a new app linked to your workspace.
- Copy the access token. App β Authentication. This is
INTERCOM_ACCESS_TOKEN. - Copy the client secret. App β Basic information (or Authentication). This is
INTERCOM_CLIENT_SECRET- the adapter uses it to verify theX-Hub-SignatureHMAC on every webhook. - Find your admin ID. Intercom requires an explicit admin for every reply. Call
GET /mewith your token and use the returnedidasINTERCOM_ADMIN_ID:curl -s https://api.intercom.io/me \ -H "Authorization: Bearer $INTERCOM_ACCESS_TOKEN" \ -H "Intercom-Version: 2.15" | jq .id
- Grant the "Read conversations" permission. Without it, the conversation webhook topics are hidden and won't be delivered.
- Configure the webhook. App β Webhooks: set the endpoint URL to your route (e.g.
https://your-app.com/api/webhooks/intercom) and subscribe to theconversation.user.createdandconversation.user.repliedtopics. For local development, expose your route with a tunnel (ngrok, Cloudflare Tunnel, etc.). - Set the region (non-US workspaces only). Set
INTERCOM_REGIONtoeuorauso requests hit the right API host.
| Feature | Status | Notes |
|---|---|---|
| Webhook ingestion + HMAC-SHA1 verification | β | Constant-time compare; fast 200 (Intercom times out at 5s) |
onNewMention |
β | conversation.user.created β isMention = true |
onSubscribedMessage |
β | conversation.user.replied |
onDirectMessage |
Works, but intercepts every conversation - see notes | |
postMessage |
β | POST /conversations/{id}/reply as admin |
editMessage |
β | Throws NotImplementedError - no edit API |
deleteMessage |
β | Redact only - the part remains, its body is cleared |
addReaction / removeReaction |
β | Throws NotImplementedError - no reactions |
fetchMessages |
β | Source + comment parts (notes/system events skipped); 500-part cap, no cursor |
fetchMessage |
β | Returns null when the part is not found |
fetchThread |
β | ThreadInfo with channelId, isDM, and title/state/createdAt in metadata |
renderFormatted |
β | mdast β HTML via marked v9 |
startTyping |
β¬ | No-op - Intercom has no admin-side typing API |
openDM |
β | Throws NotImplementedError - Intercom can't open an empty DM. Use postChannelMessage (it returns the new thread's ID) |
isDM |
Synchronous, always true |
|
postEphemeral |
β | Maps to an internal note (admin-only visible) |
stream |
β | Buffered - collects the full stream, then posts once |
postChannelMessage |
β | Admin-initiated outbound to a contact ID |
getUser |
β | GET /contacts/{id} β name, email, avatar |
Attachments (fetchData) |
β | Fetched from the Intercom file URL |
| Cards β HTML | β | Headings, text, fields, tables, links; buttons render as anchors |
| File upload (Buffer) | Not supported - logs a warning (Intercom needs public URLs) | |
| Conversation controls | β | closeConversation, openConversation, assignConversation |
- No @-mention concept.
onNewMentionfires onconversation.user.created. This is the correct SDK entry point, but the semantics differ from Slack/Teams. - Every conversation is a DM.
isDMalways returnstrue, so registeringbot.onDirectMessage()intercepts every Intercom message beforeonNewMention/onSubscribedMessage. UseonNewMention+onSubscribedMessagefor the standard support-bot pattern. concurrency: "queue"is recommended. Customers often send several quick messages. The adapter already defaultslockScopeto"channel", which serializes work per conversation; pairing it with"queue"ensures no message is dropped.- Reactions throw
NotImplementedError. Catch it if your bot shares cross-platform reaction logic. - 500-part cap.
fetchMessagesreturns at most 500 parts and offers no cursor; very long conversations are truncated. - Redact β delete.
deleteMessagecalls the redact endpoint: the part record stays, only the body is cleared. - Bodies are HTML. Intercom returns conversation bodies as HTML (even on
Intercom-Version: 2.15, which the adapter always sends). The adapter converts inbound HTML β plain text formessage.text(viaturndown+toPlainText) and into a real mdast AST formessage.formatted, so handlers and AI agents never see raw markup. Outbound replies are sent as HTML. - Admin ID is required. Intercom requires an explicit admin for every reply. Find it via
GET /meor the Developer Hub. isMeis tracked in memory. The adapter remembers the IDs it posts (bounded FIFO, 500 entries) to setisMeon echoed admin replies. In multi-instance deployments an echo may arrive at a different instance, whereisMewill befalse; such admin replies still carryauthor.isBot === "unknown". The reply topic (conversation.admin.replied) is not dispatched to handlers anyway.markedis pinned to^9. v9'smarked(str)is synchronous; v12+ is ESM-async-only and would break the format converter.
npm install
npm run typecheck # tsc --noEmit
npm test # vitest run --coverage (108 tests)
npm run build # tsup β dist/ (ESM + .d.ts)The suite has three layers, mirroring the SDK's adapter testing guide:
-
Unit (
adapter.test.ts,format-converter.test.ts,cards.test.ts,factory.test.ts,webhook.test.ts,api.test.ts) - thread-ID round-tripping, message parsing, HMAC verification, the format/card converters, factory validation, andapiCallerror mapping (each HTTP status β the right@chat-adapter/sharederror). The Intercom HTTP layer is faked atglobal.fetch, so no network is touched. -
Webhook β dispatch (
dispatch.test.ts) - usescreateMockChatInstance()andexpect(chat).toHaveDispatched("processMessage")from@chat-adapter/teststo prove user-message topics route throughprocessMessageand state-change topics don't. -
End-to-end (
integration.test.ts) - wires the real adapter into a realChatinstance with@chat-adapter/state-memory, registersonNewMention/onSubscribedMessage, and drives signed webhooks throughchat.webhooks.intercom(req, { waitUntil }). It asserts the full pipeline with the official matchers:webhook β HMAC verify β parseMessage β processMessage β handler β thread.subscribe() β thread.post() β postMessage β apiCall β fetchCovered flows: mention β subscribe β follow-up (
toHavePosted/toBeSubscribedTo), buffered streaming (thread.post(asyncIterable)posts once and never hitseditMessage), and self/state-change filtering.
Custom matchers are registered globally via setupFiles: ["@chat-adapter/tests/setup"] in vitest.config.ts.
The adapter never opens a socket - every call goes through fetch, which the tests stub. To smoke-test against a live workspace: set INTERCOM_ACCESS_TOKEN, INTERCOM_ADMIN_ID, and INTERCOM_CLIENT_SECRET, expose your webhook route with a tunnel (e.g. ngrok), register it in the Intercom Developer Hub with the conversation.user.created and conversation.user.replied topics, and start a conversation from the Messenger.
MIT