---
title: Photon
description: iMessage adapter for Chat SDK, built and maintained by Photon. Cloud, self-hosted, and on-device (local, macOS) iMessage over spectrum-ts, with HMAC-signed webhooks, tapback reactions, and DM sends that work from a cold webhook delivery.
tagline: 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.
package: @photon-ai/chat-adapter-imessage
---

# Photon



The Photon adapter connects [Chat SDK](https://chat-sdk.dev) bots to **iMessage**. It is built on [spectrum-ts](https://github.com/photon-hq/spectrum-ts), Photon's unified messaging SDK, and runs in three modes:

* **Cloud** (recommended) — connects to [Spectrum Cloud](https://app.photon.codes) 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

<PackageInstall package="@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

```typescript title="lib/bot.ts" lineNumbers
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:

```typescript
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

<TypeTable
  type={{
  local: {
    type: "boolean",
    description:
      "Run on-device (local) iMessage. Defaults to local unless `local: false`, `IMESSAGE_LOCAL=false`, or remote credentials are provided.",
  },
  projectId: {
    type: "string",
    description:
      "Spectrum Cloud project ID. Falls back to `IMESSAGE_PROJECT_ID`.",
  },
  projectSecret: {
    type: "string",
    description:
      "Spectrum Cloud project secret. Falls back to `IMESSAGE_PROJECT_SECRET`.",
  },
  serverUrl: {
    type: "string",
    description:
      "Self-host gRPC `host:port` (e.g. `imessage.example.com:443`) — not an https URL. Falls back to `IMESSAGE_SERVER_URL`.",
  },
  apiKey: {
    type: "string",
    description:
      "Auth token for a self-hosted server. Falls back to `IMESSAGE_API_KEY`.",
  },
  webhookSecret: {
    type: "string",
    description:
      "Per-webhook signing secret used to verify Spectrum Cloud deliveries. Falls back to `IMESSAGE_WEBHOOK_SECRET`.",
  },
  clients: {
    type: "{ address; token; phone }[]",
    description:
      "Explicit client list for multi-number self-host setups.",
  },
  logger: {
    type: "Logger",
    description: "Chat SDK-compatible logger. Defaults to `ConsoleLogger(\"info\")`.",
  },
}}
/>

### 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)

1. Sign up at [app.photon.codes](https://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 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](https://app.photon.codes) 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.

```typescript title="app/api/imessage/webhook/route.ts" lineNumbers
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:

```typescript
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](#limitations)).

### Gateway listener for serverless

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

```typescript title="app/api/imessage/gateway/route.ts" lineNumbers
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
  );
}
```

```json title="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 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:

```typescript
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`.

```typescript
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** — `fetchMessages` and `fetchThread` are not supported by spectrum-ts.
* **No reaction removal** — `removeReaction` 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.

## Links

* [GitHub](https://github.com/photon-hq/vercel-chat-adapter-imessage)
* [npm](https://www.npmjs.com/package/@photon-ai/chat-adapter-imessage)
* [spectrum-ts](https://github.com/photon-hq/spectrum-ts)
* [Spectrum Cloud dashboard](https://app.photon.codes)
* [Webhook docs](https://photon.codes/docs/webhooks/overview)

## Feature support

<FeatureSupport />
