Skip to main content
The iMessage adapter (chat-adapter-imessage) connects Chat SDK bots to iMessage. It’s built on spectrum-ts, so the same bot code reaches iMessage through any of three modes:
  • Cloud (recommended) — connects to Spectrum Cloud with a project ID and secret. Runs anywhere, including serverless.
  • Self-hosted — connects to your own @photon-ai/advanced-imessage gRPC endpoint.
  • Local — reads the on-device Messages database and sends through Apple’s native APIs. macOS only.
The mode is auto-detected from the options you pass (and their environment-variable fallbacks). See Configuration.

Installation

pnpm add chat chat-adapter-imessage
chat is the Chat SDK; chat-adapter-imessage is the iMessage adapter for it.

Add the adapter

Register the adapter under adapters.imessage when you construct your bot, then handle messages with the usual Chat SDK callbacks.
Connects to Spectrum Cloud over gRPC. Get your project ID and secret from the dashboard.
import { Chat } from "chat";
import { createiMessageAdapter } from "chat-adapter-imessage";

const bot = new Chat({
  userName: "mybot",
  adapters: {
    imessage: createiMessageAdapter({
      local: false,
      projectId: process.env.IMESSAGE_PROJECT_ID,
      projectSecret: process.env.IMESSAGE_PROJECT_SECRET,
    }),
  },
});

bot.onNewMention(async (thread, message) => {
  await thread.post("Hello from iMessage!");
});

Configuration

createiMessageAdapter(options) accepts the options below. Each has an environment-variable fallback, so you can configure the adapter entirely through the environment and pass no arguments.
OptionRequiredDescription
localNotrue for local, false for cloud/self-host. Defaults to local unless local: false, IMESSAGE_LOCAL=false, or remote credentials are provided.
projectIdCloudSpectrum Cloud project ID. Falls back to IMESSAGE_PROJECT_ID.
projectSecretCloudSpectrum Cloud project secret. Falls back to IMESSAGE_PROJECT_SECRET.
serverUrlSelf-hostgRPC host:port of your iMessage server. Falls back to IMESSAGE_SERVER_URL.
apiKeySelf-hostAuth token for the self-hosted server. Falls back to IMESSAGE_API_KEY.
clientsNoExplicit { address, token, phone }[] for multi-number self-host setups.
phoneNoRouting/identity phone for legacy self-host (defaults to "shared"). Falls back to IMESSAGE_PHONE.
webhookSecretNoPer-webhook signing secret for verifying Spectrum Cloud deliveries. Required to receive webhooks. Falls back to IMESSAGE_WEBHOOK_SECRET.
loggerNoLogger instance (defaults to ConsoleLogger("info")).

Environment variables

# .env.local
IMESSAGE_LOCAL=false                  # "false" for cloud/self-host (default: true)

# Cloud (recommended)
IMESSAGE_PROJECT_ID=...
IMESSAGE_PROJECT_SECRET=...

# Self-hosted (alternative)
IMESSAGE_SERVER_URL=imessage.example.com:443   # gRPC host:port (NOT an https URL)
IMESSAGE_API_KEY=...
IMESSAGE_PHONE=+1234567890                      # optional, for multi-number routing

# Webhooks (remote/cloud only)
IMESSAGE_WEBHOOK_SECRET=whsec_...               # per-webhook signing secret

Receiving messages

There are two ways to receive inbound messages:
  • Webhooks (recommended for serverless) — Spectrum Cloud delivers each message to an HTTPS endpoint as signed JSON. No long-lived connection or cron job. Cloud mode only.
  • Gateway listenerstartGatewayListener() consumes spectrum-ts’s message stream in real time. Works in all modes; in serverless it needs a cron job to stay connected.

Webhooks

In cloud mode, Spectrum Cloud delivers inbound messages to your HTTPS endpoint as signed JSON — see the webhook docs. This is the simplest path for serverless: no cron, no persistent connection.
1

Register the endpoint

In the Spectrum Cloud dashboard, register your endpoint URL (public HTTPS only) and copy the per-webhook signing secret — it is shown only once.
2

Configure the secret

Set IMESSAGE_WEBHOOK_SECRET to that signing secret. The adapter verifies the X-Spectrum-Signature HMAC on every delivery and rejects unsigned, mismatched, or stale (>5 min) requests.
IMESSAGE_WEBHOOK_SECRET=whsec_...
3

Create the webhook route

// app/api/imessage/webhook/route.ts
import { after } from "next/server";
import { bot } from "@/lib/bot";

export async function POST(request: Request): Promise<Response> {
  return bot.webhooks.imessage(request, {
    waitUntil: (task) => after(() => task),
  });
}
bot.webhooks.imessage verifies the signature, parses the messages event, and routes the message into your bot. Processing runs in the background via waitUntil, so the endpoint acknowledges immediately.
Spectrum Cloud retries failed deliveries with backoff and delivers at-least-once — dedupe on X-Spectrum-Webhook-Id + message.id if you need exactly-once side effects. A webhook delivery carries no live connection, but your bot can still respond to a DM: the adapter rebuilds the thread from its address and sends, reacts, edits, and shows typing over spectrum-ts — no gateway needed.
bot.onNewMention(async (thread, message) => {
  await thread.post("Got it!"); // works directly from a webhook delivery (DM)
});
Replying into a group still requires that group to have been received over the gateway listener in the same session — see Limitations.

Gateway listener for serverless

The gateway listener keeps a live spectrum-ts stream open. In serverless, run it on a cron so it reconnects before each window expires.
1

Create the gateway route

// app/api/imessage/gateway/route.ts
import { after } from "next/server";
import { bot } from "@/lib/bot";

export const maxDuration = 800;

export async function GET(request: Request): Promise<Response> {
  const cronSecret = process.env.CRON_SECRET;
  if (!cronSecret) {
    return new Response("CRON_SECRET not configured", { status: 500 });
  }

  const authHeader = request.headers.get("authorization");
  if (authHeader !== `Bearer ${cronSecret}`) {
    return new Response("Unauthorized", { status: 401 });
  }

  const durationMs = 600 * 1000;

  return bot.adapters.imessage.startGatewayListener(
    { waitUntil: (task) => after(() => task) },
    durationMs
  );
}
2

Configure Vercel Cron

// vercel.json
{
  "crons": [
    {
      "path": "/api/imessage/gateway",
      "schedule": "*/9 * * * *"
    }
  ]
}
This runs every 9 minutes, overlapping the 10-minute listener duration so the stream is never down. Vercel adds CRON_SECRET automatically when you configure cron jobs.

Feature support

“Remote” below means cloud or self-hosted mode — anything other than local: true.
FeatureSupported
MentionsDMs only
DMsYes
File uploadsYes (send)
Reactions (add)Remote only
Reactions (remove)No
Message editingRemote only
Typing indicatorRemote only
ModalsLimited (remote only)
Message historyNo
Thread/chat infoNo
CardsNo
StreamingNo
Ephemeral messagesNo
WebhooksYes (remote — Spectrum Cloud delivery)

Modals

Remote mode supports limited modals by mapping Chat SDK’s openModal() to iMessage native polls. Only Select children are supported — the first Select in the modal becomes a poll:
  • Modal.title becomes the poll question.
  • Select.options become the poll choices (2–10 supported).
  • Votes trigger onModalSubmit with the selected option’s value.
import { Chat, Modal, Select, SelectOption } from "chat";
import { createiMessageAdapter } from "chat-adapter-imessage";

const bot = new Chat({
  userName: "mybot",
  adapters: {
    imessage: createiMessageAdapter({ local: false }),
  },
});

bot.onNewMention(async (thread, message) => {
  await message.openModal(
    Modal({
      callbackId: "fav-color",
      title: "What is your favorite color?",
      children: [
        Select({
          id: "color",
          label: "Pick a color",
          options: [
            SelectOption({ label: "Red", value: "red" }),
            SelectOption({ label: "Blue", value: "blue" }),
            SelectOption({ label: "Green", value: "green" }),
          ],
        }),
      ],
    })
  );
});

bot.onModalSubmit("fav-color", async (event) => {
  const color = event.values.color; // "red", "blue", or "green"
});
Polls in the same chat must have distinct titles — votes are matched back to the modal by title. Local mode throws NotImplementedError.
Not supported: Select.placeholder/label, TextInput, RadioSelect, Modal.submitLabel/closeLabel, more than one Select, and poll vote deselection.

Tapback reactions

iMessage uses tapbacks instead of emoji reactions. The adapter maps standard emoji names to iMessage tapbacks.
Emoji nameTapback
love / heartLove
like / thumbs_upLike
dislike / thumbs_downDislike
laughLaugh
emphasize / exclamationEmphasize
questionQuestion

Limitations

  • DMs send cold; groups are session-bound. For a DM, the adapter rebuilds the thread from its address over gRPC, so it can send, react, edit, and show typing even into a thread it hasn’t seen this session — including a webhook delivery. A group has no by-id resolver, so addressing one requires it to have been received over the gateway/stream in the current session; cold sends to an unseen group throw NotImplementedError. Local mode cannot create spaces at all — it only replies to received messages.
  • No message history. fetchMessages is not supported — spectrum-ts exposes no paginated history API.
  • No thread/chat info. fetchThread is not supported.
  • No reaction removal. removeReaction is not supported.
  • Local mode supports sending and receiving, but not reactions, typing, editing, modals, history, or thread info.
  • Formatting. iMessage is plain-text only; Markdown formatting is stripped when sending, preserving the text content.
  • Platform. Local mode requires macOS. Cloud and self-host run anywhere.
  • Cards. iMessage has no structured card layouts.

Troubleshooting

Provide cloud credentials (IMESSAGE_PROJECT_ID + IMESSAGE_PROJECT_SECRET), or a self-host IMESSAGE_SERVER_URL + IMESSAGE_API_KEY.
Confirm IMESSAGE_SERVER_URL is a gRPC host:port (e.g. imessage.example.com:443), not an https:// URL. Verify the token matches your server’s credentials.
Verify Full Disk Access is granted to your terminal or application, and that iMessage is signed in and working on the Mac.
These are not supported by spectrum-ts. See Limitations.