Photon

iMessage adapter for Chat SDK — run against Spectrum Cloud, your own gRPC server, or on-device on a Mac — mapping iMessage chats to the Chat SDK thread/message/reaction model with signed webhooks and native tapbacks.

Vendor-official adapter maintained by Photon, not Vercel or Chat SDK contributors. For feature requests, bug reports, and support, file an issue on the adapter's repo.

The Photon adapter connects Chat SDK bots to iMessage. It is built on spectrum-ts, Photon's unified messaging SDK, and runs in 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 — runs directly on a Mac, reading the on-device iMessage database. macOS only.

The mode is auto-detected from environment variables. The adapter maps an iMessage chat to a Chat SDK thread, a text to a message, and a tapback to a reaction, so subscriptions, handlers, posts, and reactions work the same as with any other adapter.

Install

pnpm add @photon-ai/chat-adapter-imessage chat @chat-adapter/state-memory

For production, use a persistent state adapter such as @chat-adapter/state-redis instead of in-memory state.

Quick start

lib/bot.ts
import { Chat } from "chat";
import { createMemoryState } from "@chat-adapter/state-memory";
import { createiMessageAdapter } from "@photon-ai/chat-adapter-imessage";

export const bot = new Chat({
  userName: "mybot",
  state: createMemoryState(),
  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(`You said: ${message.text}`);
});

For local development on a Mac, drop the credentials and let local mode default on:

createiMessageAdapter({ local: true });

Local mode requires macOS with iMessage signed in and Full Disk Access granted to your terminal or app (System Settings → Privacy & Security → Full Disk Access).

Configuration

Prop

Type

Environment variables

VariableRequiredDescription
IMESSAGE_LOCALNo"false" for cloud/self-host; local mode is the default.
IMESSAGE_PROJECT_IDCloudSpectrum Cloud project ID.
IMESSAGE_PROJECT_SECRETCloudSpectrum Cloud project secret.
IMESSAGE_SERVER_URLSelf-hostgRPC host:port of your iMessage server (not an https URL).
IMESSAGE_API_KEYSelf-hostAuth token for the self-hosted server.
IMESSAGE_WEBHOOK_SECRETWebhooksPer-webhook signing secret for verifying Spectrum Cloud deliveries.
IMESSAGE_PHONENoRouting/identity phone for multi-number setups.

Platform setup

  1. Sign up at app.photon.codes to get your project ID and project secret.
  2. Set IMESSAGE_PROJECT_ID, IMESSAGE_PROJECT_SECRET, and IMESSAGE_LOCAL=false.

Self-hosted

Point the adapter at your own @photon-ai/advanced-imessage gRPC server.

  1. Set IMESSAGE_SERVER_URL to the server's gRPC address as host:port (e.g. imessage.example.com:443) — not an https:// URL.
  2. Set IMESSAGE_API_KEY to the server's auth token, and IMESSAGE_LOCAL=false.

Local

  1. Grant Full Disk Access to your terminal or app.
  2. Ensure iMessage is signed in and working on the Mac. Local mode is the default — no extra environment variables are required.

Receiving messages

There are two ways to receive inbound messages in remote (cloud) mode:

  • Webhooks (recommended for serverless) — Spectrum Cloud delivers each message to an HTTPS endpoint as signed JSON. No long-lived connection or cron job.
  • Gateway listenerstartGatewayListener() consumes the spectrum-ts message stream in real time. Works in all modes; in serverless it needs a cron job to stay connected.

Webhooks

Register your endpoint URL in the Spectrum Cloud dashboard and copy the per-webhook signing secret (shown only once) into IMESSAGE_WEBHOOK_SECRET. The adapter verifies the X-Spectrum-Signature HMAC on every delivery and rejects unsigned, mismatched, or stale (>5 min) requests.

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 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. For a DM, the adapter rebuilds the thread from its address over spectrum-ts (gRPC) and can send, react, edit, and show typing — no gateway needed:

bot.onNewMention(async (thread, message) => {
  await thread.post("Got it!"); // works directly from a webhook delivery (DM)
});

Replying into a group requires that the group was received over the gateway listener in the same session — an unseen group cannot be reconstructed from its id (see Limitations).

Gateway listener for serverless

Drive startGatewayListener() from an authenticated cron route so the stream stays connected:

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 });
  }
  if (request.headers.get("authorization") !== `Bearer ${cronSecret}`) {
    return new Response("Unauthorized", { status: 401 });
  }

  const durationMs = 600 * 1000;
  return bot.adapters.imessage.startGatewayListener(
    { waitUntil: (task) => after(() => task) },
    durationMs
  );
}
vercel.json
{
  "crons": [{ "path": "/api/imessage/gateway", "schedule": "*/9 * * * *" }]
}

Running every 9 minutes overlaps the 10-minute listener duration. CRON_SECRET is added automatically by Vercel when you configure cron jobs.

Message format

iMessage is plain text only. Outbound markdown is rendered to plain text (formatting is stripped, content preserved), and inbound text is parsed into the Chat SDK AST.

Reactions

iMessage uses tapbacks instead of emoji reactions. The adapter maps standard emoji names to tapbacks when adding a reaction (remote mode only):

Emoji nameTapback
love / heartLove
like / thumbs_upLike
dislike / thumbs_downDislike
laughLaugh
emphasize / exclamationEmphasize
questionQuestion

Removing reactions is not supported.

Attachments

fileUploads are supported for sending. Attach files to a post and they are delivered as iMessage media:

import { readFile } from "node:fs/promises";

await thread.post({
  markdown: "Here is the receipt.",
  files: [
    {
      filename: "receipt.pdf",
      mimeType: "application/pdf",
      data: await readFile("receipt.pdf"),
    },
  ],
});

Modals (limited)

Remote mode maps the Chat SDK's openModal() to iMessage native polls. Only the first Select in the modal is used: Modal.title becomes the poll question and Select.options (2–10) become the choices. Votes fire onModalSubmit with the selected option's value.

bot.onModalSubmit("fav-color", async (event) => {
  const color = event.values.color; // e.g. "red"
});

Not supported: TextInput, RadioSelect, placeholders, submit/close labels, more than one Select, and vote deselection. Polls in the same chat must have distinct titles. Local mode throws NotImplementedError.

Limitations

  • DMs send cold; groups are session-bound. A DM thread is rebuilt from its address over gRPC, so the adapter can send, react, edit, and show typing even into a thread it has not seen this session (including from a webhook). A group chat 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.
  • No message history or thread infofetchMessages and fetchThread are not supported by spectrum-ts.
  • No reaction removalremoveReaction is not supported.
  • Remote-only capabilities — reactions, typing, editing, and modals require cloud or self-host mode. Local mode supports sending and receiving only.
  • Plain text — iMessage has no markdown or structured cards.
  • Platform — local mode requires macOS; cloud and self-host run anywhere.

Feature support

Messaging

FeatureSupported
Post message
Edit messageRemote only
Delete message
File uploadsSend
Streaming
Scheduled messages

Rich content

FeatureSupported
Card format
Buttons
Link buttons
Select menus
Tables
Fields
Images in cards
ModalsRemote only, native polls

Conversations

FeatureSupported
Slash commands
MentionsDMs only
Add reactionsTapbacks, remote only
Remove reactions
Typing indicatorRemote only
DMs
Ephemeral messages
User lookup
Parent subject
Native client
Custom API endpoint

Message history

FeatureSupported
Fetch messages
Fetch single message
Fetch thread info
Fetch channel messages
List threads
Fetch channel info
Post channel message