chat-adapter-intercom
v1.0.0
Published
Intercom adapter for Chat SDK
Maintainers
Readme
chat-adapter-intercom
An Intercom adapter for the Chat SDK - connect your own AI agent or bot to Intercom conversations.
Why
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.
Installation
npm install chat-adapter-intercom chatchat is a peer dependency - you bring
your own Chat SDK instance and register this adapter on it.
Quick start
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
- 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.
Configuration
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") |
Intercom setup
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.
Capability matrix
| 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 |
Intercom-specific behaviours
- 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.
Development
npm install
npm run typecheck # tsc --noEmit
npm test # vitest run --coverage (108 tests)
npm run build # tsup → dist/ (ESM + .d.ts)How it's tested
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.
Testing against real Intercom
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.
License
MIT
