@photon-ai/advanced-imessage
TypeScript SDK for the Advanced iMessage server. Supports two transports:
HTTP and gRPC — pick per project, or mix them.
- HTTP (default): the SDK talks
fetch to the REST middleware
(imessage-server-v2-http),
which forwards to the iMessage plane — no gRPC in the client, so it runs
wherever fetch exists: Cloudflare Workers, edge runtimes, browsers,
Node, Bun, Deno. Inbound events ride webhooks — see
Inbound events.
- gRPC: the full v1 surface, including the live
subscribeEvents/watch/events.catchUp streams. Node/Bun only.
Install
HTTP
bun add @photon-ai/advanced-imessage
import { createHttpClient } from "@photon-ai/advanced-imessage";
const im = createHttpClient({
address: "http://localhost:8080",
token: process.env.IMESSAGE_TOKEN!,
});
const sent = await im.messages.sendText("any;-;alice@example.com", "hello");
console.log(sent.guid);
await im.close();
Runs on any runtime with fetch (Cloudflare Workers, Node >=18.17, Bun,
Deno, browsers). The package is ESM-only. Workers compatibility is enforced
in CI: every build bundles the SDK and boots it in workerd.
gRPC
The gRPC transport needs its peer dependencies.
bun add @photon-ai/advanced-imessage nice-grpc nice-grpc-common @grpc/grpc-js
import { createClient } from "@photon-ai/advanced-imessage/grpc";
const im = createClient({
address: "127.0.0.1:50051",
token: process.env.IMESSAGE_TOKEN!,
tls: false,
});
const sent = await im.messages.sendText("any;-;alice@example.com", "hello");
console.log(sent.guid);
for await (const event of im.messages.subscribeEvents()) {
console.log(event.type, event.sequence);
}
await im.close();
Node >=18.17 or Bun (native gRPC — not available on fetch-only runtimes
like Workers).
Entrypoints
One package, three entrypoints:
@photon-ai/advanced-imessage | createHttpClient, createGrpcClient, and every shared type. HTTP-flavored: ClientOptions etc. are the HTTP client's. |
@photon-ai/advanced-imessage/http | The HTTP transport only. Safe everywhere fetch exists. |
@photon-ai/advanced-imessage/grpc | The gRPC transport: the full v1 surface, including events and the live subscribeEvents/watch streams. Node/Bun only. |
gRPC requires its optional peer dependencies (see Install); HTTP-only installs never download or evaluate them.
Migrating from v1
v1 of this package was gRPC-only, exported from the package root. Your code
keeps working with two changes — an import specifier and the peer install
above:
-import { createClient, type ClientOptions } from "@photon-ai/advanced-imessage";
+import { createClient, type ClientOptions } from "@photon-ai/advanced-imessage/grpc";
Every v1 export keeps its name on the /grpc subpath (createClient is an
alias of createGrpcClient). Live event streams stay gRPC-only — over HTTP,
inbound events ride webhooks instead (see Inbound events).
Connect
import { createHttpClient } from "@photon-ai/advanced-imessage";
const im = createHttpClient({
address: "http://localhost:8080",
token: process.env.IMESSAGE_TOKEN!,
});
await im.close();
address is the imessage-server-v2-http middleware: a bare host[:port] or
a full http(s):// URL. token may also be an async function when
credentials rotate — it is resolved fresh for every call:
const im = createHttpClient({
address: "imessage.example.com",
token: async () => process.env.IMESSAGE_TOKEN!,
});
Bare addresses default to https; set tls: false only for local
development.
Shared and dedicated routing
The HTTP client uses the shared proxy by default. Omit server and the SDK
will not send an x-photon-server header:
const im = createHttpClient({
address: "imessage.example.com",
token: process.env.IMESSAGE_TOKEN!,
});
To route requests to a dedicated iMessage instance, set server to the
instance ID assigned by Photon and use a token that instance accepts:
const im = createHttpClient({
address: "imessage.example.com",
token: process.env.IMESSAGE_TOKEN!,
server: "instance-abc",
});
This sends x-photon-server: instance-abc on every request. The server
value is the dedicated instance ID, not the middleware address or bearer
token.
Chat GUIDs
Methods that take chat expect a server chat guid:
const direct = "any;-;alice@example.com";
const group = "any;+;group-chat-guid";
In normal code, pass chat.guid returned by im.chats.create(...),
im.chats.get(...), message results, or event payloads. The SDK does not turn
bare phone numbers, emails, or group IDs into chat GUIDs.
Send Messages
import { MessageEffect, TextEffect } from "@photon-ai/advanced-imessage";
const chatGuid = "any;-;alice@example.com";
const sent = await im.messages.sendText(chatGuid, "Happy birthday", {
effect: MessageEffect.confetti,
formatting: [{ type: "effect", start: 0, length: 5, effect: TextEffect.bloom }],
enableLinkPreview: true,
});
console.log(sent.guid);
Reply to a whole message:
await im.messages.sendText(chatGuid, "reply", {
replyTo: sent.guid,
});
Reply to one bubble in a multipart message:
await im.messages.sendText(chatGuid, "reply to part 2", {
replyTo: { guid: sent.guid, partIndex: 2 },
});
Send Attachments
Attachments are sent by uploaded attachment GUID.
import { readFile } from "node:fs/promises";
const jpegBytes = await readFile("photo.jpg");
const uploaded = await im.attachments.upload({
fileName: "photo.jpg",
data: jpegBytes,
});
await im.messages.sendAttachment(chatGuid, uploaded.attachment.guid);
The SDK uploads raw bytes and returns a server-hosted attachment GUID. Use that
GUID with messages.sendAttachment(...), attachments.get(...), or
attachments.downloadStream(...). The SDK does not expose server-local file
paths; this matters when the SDK and server run on different machines.
Upload, metadata lookup, and download have been live-tested with these
attachment formats:
- Images:
jpg, png, gif, tiff, bmp, webp, avif, svg
- Video:
mov, mp4, webm
- Audio:
aiff, caf, flac, m4a, mp3, ogg, wav
- Text and structured text:
txt, md, csv, json, html, xml, rtf
- Documents:
pdf, docx, xlsx, pptx
- Contact and calendar:
vcf, ics
- Archives and compressed payloads:
zip, tar, tar.gz, tgz,
tar.bz2, tar.xz, gz, bz2, xz
Downloads are streamed by GUID and preserve byte-for-byte content. The first
frame is metadata, followed by primary payload chunks:
for await (const frame of im.attachments.downloadStream(uploaded.attachment.guid)) {
if (frame.type === "header") {
console.log(frame.info.mimeType, frame.info.uti);
}
if (frame.type === "primaryChunk") {
}
}
Live Photo companions transfer over HTTP: attachments.upload(...) with a
companion sends multipart/form-data (file + companion parts), and
downloadStream(...) yields the v1 frame sequence — a header frame carrying
companionInfo, the primary chunks, then the companionChunk frames fetched
from the middleware's /v1/attachments/{guid}/companion route.
7z and rar are not currently listed as tested formats because the current
server test workspace does not include real encoders for those archive types.
Fake files are not treated as supported fixtures.
Chat Backgrounds
Chat backgrounds are not general attachments. They use chat GUIDs and raw image
bytes:
await im.chats.setBackground(
"any;-;alice@example.com",
await readFile("photo.jpg")
);
const present = await im.chats.hasBackground("any;-;alice@example.com");
await im.chats.removeBackground("any;-;alice@example.com");
Supported and live-tested background image MIME types:
image/jpeg
image/png
image/heic
image/heif
Callers do not pass a MIME type. The server infers the format from the bytes and
rejects image/gif, image/webp, image/avif, image/tiff, image/bmp, and
image/svg+xml for chat backgrounds. Those formats may still be uploaded and
sent as normal attachments; the background pipeline is stricter because the
server converts the input image into Apple's background package format.
To use one of Messages' built-in backgrounds instead of an image, pass its
name:
await im.chats.setBackground("any;-;alice@example.com", "skyDusk");
- Sky:
skyDusk, skyHaze, skySunset, skyClear, skySunrise, skyDawn
- Water:
waterLight, waterDark
- Aurora:
auroraGreen, auroraPurple, auroraPink
- Color:
colorMango, colorIce, colorPlum, colorDeepSea,
colorGreenApple, colorCherry, colorBubblegum, colorTangerine,
colorMagenta, colorLime, colorSilver, colorCarbon, colorStone
Multipart sends are atomic and can mix text, mentions, and uploaded
attachments:
await im.messages.sendMultipart(chatGuid, [
{ text: "look at this " },
{ text: "@Alice", mentionedAddress: "alice@example.com" },
{
attachmentGuid: uploaded.attachment.guid,
attachmentName: "photo.jpg",
},
]);
Mutate Messages
import { readFile } from "node:fs/promises";
await im.messages.edit(chatGuid, sent.guid, "updated text");
await im.messages.unsend(chatGuid, sent.guid);
await im.messages.setReaction(chatGuid, sent.guid, { kind: "love" }, true);
await im.messages.setReaction(chatGuid, sent.guid, { kind: "love" }, false);
const sticker = await im.attachments.upload({
fileName: "sticker.png",
data: await readFile("sticker.png"),
});
await im.messages.placeSticker(chatGuid, sent.guid, sticker.attachment.guid, {
x: 120,
y: 90,
});
For multipart messages, pass partIndex in mutation options to target one
bubble.
Read Messages
const message = await im.messages.get(sent.guid);
const recent = await im.messages.listRecent({ pageSize: 25 });
const inChat = await im.messages.listInChat(chatGuid, {
pageSize: 25,
before: new Date(),
});
pageSize, when provided, must be between 1 and 100.
Inbound events
The HTTP client is outbound-only. It has no client-held event streams —
long-lived gRPC streams don't exist on fetch-only runtimes, so over HTTP
inbound delivery is the platform's job, not the client's. (The
subscribeEvents(...) / watch(...) / events.catchUp(...) streaming APIs
remain available on the gRPC transport.)
Over HTTP, to receive messages and other events, register a webhook for
your project (via Spectrum) and reply from your handler using this SDK. A
typical Cloudflare Worker:
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const event = await request.json();
if (event.type === "message.received") {
const im = createHttpClient({
address: env.IMESSAGE_HTTP_ADDRESS,
token: () => mintToken(env),
});
await im.messages.sendText(event.chat.guid, "got it!");
}
return new Response("ok");
},
};
Transport adapters that already receive an encoded
CatchUpEventsResponse frame can map it through the same public event model
without importing generated protobuf internals:
import { decodeCatchUpEvent } from "@photon-ai/advanced-imessage";
const event = decodeCatchUpEvent(frameBytes);
if (event?.type === "message.received") {
console.log(event.message.guid);
}
Heartbeat and payload-less frames return undefined; errors reported by the
generated protobuf decoder propagate to the caller.
Write responses remain authoritative: use the return value of a send/mutate
call as the result of that write, not a later event.
downloadStream(...) still returns a TypedEventStream<T> (backed by the
HTTP response stream). Streams support for await, .on(...),
.filter(...), .map(...), .take(...), .close(), and await using.
gRPC heartbeats
Set onHeartbeat on a subscription to track that stream separately:
let lastMessageHeartbeatAt: number | undefined;
const messages = im.messages.subscribeEvents({
onHeartbeat: () => {
lastMessageHeartbeatAt = Date.now();
},
});
try {
for await (const event of messages) {
await handleMessage(event);
}
} finally {
await messages.close();
}
The same option is available on chats, groups, and polls
subscribeEvents(...). For locations, use
im.locations.watch(address, { onHeartbeat }).
A subscription callback replaces the client-level onHeartbeat for that
subscription; it does not call both. Without it, the existing client callback
still runs. If neither is set, heartbeat frames are ignored as before. Hooks
are local callbacks and are not sent to the server.
Each callback belongs to one subscription. When reconnecting, close the old
subscription before opening its replacement. Use separate closures to retain your own
project, line, stream and connection-generation identity. Closing the stream
aborts its RPC and suppresses callbacks from late frames.
Heartbeats are consumed when the iterator reads frames. A quiet stream can
receive them without messages, but a slow or blocked event handler can delay
reading the next heartbeat. A heartbeat is not a delivery acknowledgement
or proof that message processing has finished. Do not use a sibling stream's
heartbeat, or ordinary message silence, to decide this stream is healthy or
needs a restart. No watchdog, timeout or reconnect is added by this option.
Keep hooks synchronous and do not throw; a thrown error fails that stream.
Returned promises are not awaited.
Other Resources
await im.addresses.get("alice@example.com");
await im.addresses.isIMessageAvailable("alice@example.com");
await im.addresses.isFocusSilenced("alice@example.com");
const created = await im.chats.create(["alice@example.com"], {
message: "hello",
});
await im.chats.markRead(created.chat.guid);
await im.chats.setTyping(created.chat.guid, true);
const group = await im.chats.create(["alice@example.com", "bob@example.com"]);
await im.groups.setDisplayName(group.chat.guid, "Weekend");
await im.groups.addParticipants(group.chat.guid, ["carol@example.com"]);
await im.groups.getIcon(group.chat.guid);
const poll = await im.polls.create(created.chat.guid, "Lunch?", [
"Sushi",
"Pizza",
]);
await im.polls.vote(poll.pollMessageGuid, poll.options[0]!.optionIdentifier);
await im.locations.list();
await im.locations.get("alice@example.com");
Errors
Server errors are mapped to SDK error classes:
import {
AuthenticationError,
NotFoundError,
RateLimitError,
ValidationError,
} from "@photon-ai/advanced-imessage";
try {
await im.messages.sendText(chatGuid, "hello");
} catch (error) {
if (error instanceof RateLimitError) {
console.log(error.retryable, error.context);
}
if (error instanceof NotFoundError) {
console.log(error.code);
}
if (error instanceof AuthenticationError) {
console.log("refresh credentials");
}
if (error instanceof ValidationError) {
console.log(error.context);
}
}
Client Options
const im = createHttpClient({
address: "http://localhost:8080",
token: "api-token",
timeout: 10_000,
retry: { maxAttempts: 4, initialDelay: 200, maxDelay: 5_000 },
autoIdempotency: true,
});
timeout applies per call. retry retries only failures the server
explicitly marked retryable, with exponential backoff and jitter; the
idempotency key (when enabled) is generated once per logical call and reused
across attempts, so retries dedupe server-side. autoIdempotency adds the
key only to mutating calls.
For dedupe across client restarts, pass clientMessageId in a send's
options — the server rejects a repeated clientMessageId with a
duplicateMessage error, which means the original send succeeded.
Development
bun install
bun run check
bun run lint
bun test
bun run build
The repo is a bun-workspaces monorepo; only packages/advanced-imessage
publishes to npm — it bundles the private workspace packages:
packages/core — shared types, errors, streaming, proto↔public mapper,
generated proto codecs
packages/http — the fetch transport and the live resources
packages/grpc — the v1-compatible gRPC transport behind a lazy façade
bun run build regenerates protobuf output from
buf.build/photon-hq/imessage, the
canonical source published by the server's CI — pinned to a BSR commit in
buf.gen.yaml; CI fails if committed codegen drifts from the pin — and
builds packages/advanced-imessage/dist/.
bun run generate:http regenerates the
HTTP route client (packages/http/src/generated/http) from the middleware's
OpenAPI spec (gen/openapi/imessage.swagger.json). After a build,
bun test tests/dist gates the published artifact: single class identity
across entrypoints and no static nice-grpc in the index/http graphs.
CI also bundles the SDK for workerd and boots it (tests/workerd), so a
Workers-hostile dependency cannot land unnoticed.