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.
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-imessagegRPC 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-memoryFor production, use a persistent state adapter such as @chat-adapter/state-redis instead of in-memory state.
Quick start
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
| Variable | Required | Description |
|---|---|---|
IMESSAGE_LOCAL | No | "false" for cloud/self-host; local mode is the default. |
IMESSAGE_PROJECT_ID | Cloud | Spectrum Cloud project ID. |
IMESSAGE_PROJECT_SECRET | Cloud | Spectrum Cloud project secret. |
IMESSAGE_SERVER_URL | Self-host | gRPC host:port of your iMessage server (not an https URL). |
IMESSAGE_API_KEY | Self-host | Auth token for the self-hosted server. |
IMESSAGE_WEBHOOK_SECRET | Webhooks | Per-webhook signing secret for verifying Spectrum Cloud deliveries. |
IMESSAGE_PHONE | No | Routing/identity phone for multi-number setups. |
Platform setup
Cloud (recommended)
- Sign up at app.photon.codes to get your project ID and project secret.
- Set
IMESSAGE_PROJECT_ID,IMESSAGE_PROJECT_SECRET, andIMESSAGE_LOCAL=false.
Self-hosted
Point the adapter at your own @photon-ai/advanced-imessage gRPC server.
- Set
IMESSAGE_SERVER_URLto the server's gRPC address ashost:port(e.g.imessage.example.com:443) — not anhttps://URL. - Set
IMESSAGE_API_KEYto the server's auth token, andIMESSAGE_LOCAL=false.
Local
- Grant Full Disk Access to your terminal or app.
- 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 listener —
startGatewayListener()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.
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:
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
);
}{
"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 name | Tapback |
|---|---|
love / heart | Love |
like / thumbs_up | Like |
dislike / thumbs_down | Dislike |
laugh | Laugh |
emphasize / exclamation | Emphasize |
question | Question |
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 info —
fetchMessagesandfetchThreadare not supported by spectrum-ts. - No reaction removal —
removeReactionis 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.
Links
Feature support
Messaging
| Feature | Supported |
|---|---|
| Post message | |
| Edit message | Remote only |
| Delete message | |
| File uploads | Send |
| Streaming | |
| Scheduled messages |
Rich content
| Feature | Supported |
|---|---|
| Card format | |
| Buttons | |
| Link buttons | |
| Select menus | |
| Tables | |
| Fields | |
| Images in cards | |
| Modals | Remote only, native polls |
Conversations
| Feature | Supported |
|---|---|
| Slash commands | |
| Mentions | DMs only |
| Add reactions | Tapbacks, remote only |
| Remove reactions | |
| Typing indicator | Remote only |
| DMs | |
| Ephemeral messages | |
| User lookup | |
| Parent subject | |
| Native client | |
| Custom API endpoint |
Message history
| Feature | Supported |
|---|---|
| Fetch messages | |
| Fetch single message | |
| Fetch thread info | |
| Fetch channel messages | |
| List threads | |
| Fetch channel info | |
| Post channel message |