npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

chat-adapter-intercom

v1.0.0

Published

Intercom adapter for Chat SDK

Readme

chat-adapter-intercom

An Intercom adapter for the Chat SDK - connect your own AI agent or bot to Intercom conversations.

npm version npm downloads CI license: MIT

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 chat

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

  1. Create an app. In the Intercom Developer Hub, create a new app linked to your workspace.
  2. Copy the access token. App → Authentication. This is INTERCOM_ACCESS_TOKEN.
  3. Copy the client secret. App → Basic information (or Authentication). This is INTERCOM_CLIENT_SECRET - the adapter uses it to verify the X-Hub-Signature HMAC on every webhook.
  4. Find your admin ID. Intercom requires an explicit admin for every reply. Call GET /me with your token and use the returned id as INTERCOM_ADMIN_ID:
    curl -s https://api.intercom.io/me \
      -H "Authorization: Bearer $INTERCOM_ACCESS_TOKEN" \
      -H "Intercom-Version: 2.15" | jq .id
  5. Grant the "Read conversations" permission. Without it, the conversation webhook topics are hidden and won't be delivered.
  6. Configure the webhook. App → Webhooks: set the endpoint URL to your route (e.g. https://your-app.com/api/webhooks/intercom) and subscribe to the conversation.user.created and conversation.user.replied topics. For local development, expose your route with a tunnel (ngrok, Cloudflare Tunnel, etc.).
  7. Set the region (non-US workspaces only). Set INTERCOM_REGION to eu or au so 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.createdisMention = 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

  1. No @-mention concept. onNewMention fires on conversation.user.created. This is the correct SDK entry point, but the semantics differ from Slack/Teams.
  2. Every conversation is a DM. isDM always returns true, so registering bot.onDirectMessage() intercepts every Intercom message before onNewMention/onSubscribedMessage. Use onNewMention + onSubscribedMessage for the standard support-bot pattern.
  3. concurrency: "queue" is recommended. Customers often send several quick messages. The adapter already defaults lockScope to "channel", which serializes work per conversation; pairing it with "queue" ensures no message is dropped.
  4. Reactions throw NotImplementedError. Catch it if your bot shares cross-platform reaction logic.
  5. 500-part cap. fetchMessages returns at most 500 parts and offers no cursor; very long conversations are truncated.
  6. Redact ≠ delete. deleteMessage calls the redact endpoint: the part record stays, only the body is cleared.
  7. 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 for message.text (via turndown + toPlainText) and into a real mdast AST for message.formatted, so handlers and AI agents never see raw markup. Outbound replies are sent as HTML.
  8. Admin ID is required. Intercom requires an explicit admin for every reply. Find it via GET /me or the Developer Hub.
  9. isMe is tracked in memory. The adapter remembers the IDs it posts (bounded FIFO, 500 entries) to set isMe on echoed admin replies. In multi-instance deployments an echo may arrive at a different instance, where isMe will be false; such admin replies still carry author.isBot === "unknown". The reply topic (conversation.admin.replied) is not dispatched to handlers anyway.
  10. marked is pinned to ^9. v9's marked(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:

  1. 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, and apiCall error mapping (each HTTP status → the right @chat-adapter/shared error). The Intercom HTTP layer is faked at global.fetch, so no network is touched.

  2. Webhook → dispatch (dispatch.test.ts) - uses createMockChatInstance() and expect(chat).toHaveDispatched("processMessage") from @chat-adapter/tests to prove user-message topics route through processMessage and state-change topics don't.

  3. End-to-end (integration.test.ts) - wires the real adapter into a real Chat instance with @chat-adapter/state-memory, registers onNewMention/onSubscribedMessage, and drives signed webhooks through chat.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 → fetch

    Covered flows: mention → subscribe → follow-up (toHavePosted / toBeSubscribedTo), buffered streaming (thread.post(asyncIterable) posts once and never hits editMessage), 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