Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.photon.codes/docs/llms.txt

Use this file to discover all available pages before exploring further.

@photon-ai/imessage-kit is an MIT-licensed, macOS-only SDK that talks directly to the local Messages database and AppleScript bridge. Use it when you want to read, send, and automate iMessage on a Mac you control — automation tools, AI agents, chat-first apps.
This SDK is for talking to iMessage locally on a Mac you control. If you want remote/hosted iMessage delivery, threaded replies, tapbacks, edits / unsends, and live typing indicators, use Spectrum for a unified API across platforms, or @photon-ai/advanced-imessage when you need low-level iMessage control.

Requirements

  • OS: macOS only (reads the Messages SQLite database directly).
  • Runtime: Node.js ≥ 20 or Bun ≥ 1.0.
  • Permission: the process must have Full Disk Access granted. Open System Settings → Privacy & Security → Full Disk Access and add your terminal or IDE (Terminal, iTerm2, Warp, VS Code, Cursor…).

Install

bun add @photon-ai/imessage-kit
On Bun the SDK has zero runtime dependencies. On Node it uses better-sqlite3 as an optional peer dependency.

Quick start

import { IMessageSDK } from "@photon-ai/imessage-kit";

const sdk = new IMessageSDK();

await sdk.send("+1234567890", "Hello from iMessage Kit!");

await sdk.close();
The constructor takes an optional — override the default Messages database path, concurrent-send limit, debug logging, webhooks, and plugins.
OptionTypeDescription
databasePathstringDatabase path Default: ~/Library/Messages/chat.db
webhookWebhookConfigWebhook configuration (optional)
watcherWatcherConfigWatcher configuration (optional)
retryRetryConfigRetry configuration (optional)
tempFileTempFileConfigTemporary file configuration (optional)
scriptTimeoutnumberAppleScript execution timeout In milliseconds (default: 30000)
maxConcurrentnumberMaximum concurrent sends Default: 5, 0 means unlimited
debugbooleanDebug mode (default: false)
pluginsreadonly Plugin[]Plugin list (optional)

Sending

sdk.send(to, content) auto-detects whether to is a recipient (phone number / email) or a chatId (group or DM).

Text

await sdk.send("+1234567890", "Hello World!");
await sdk.send("user@example.com", "Hello!");

Images and files

// Images (auto-selects iMessage image handling)
await sdk.send("+1234567890", { images: ["/path/to/photo.jpg"] });

// Arbitrary files
await sdk.send("+1234567890", { files: ["/path/to/document.pdf"] });

// Text + attachments
await sdk.send("+1234567890", {
  text: "Check this out!",
  images: ["/path/to/photo.jpg"],
  files: ["/path/to/report.pdf"],
});

// Convenience wrappers
await sdk.sendFile("+1234567890", "/path/to/document.pdf");
await sdk.sendFiles("+1234567890", ["/file1.pdf", "/file2.csv"], "Multiple files");

Groups

Group chatIds are returned by listChats():
const groups = await sdk.listChats({ type: "group" });
const chatId = groups[0].chatId;

await sdk.send(chatId, "Hello group!");
await sdk.send(chatId, { text: "Check these files", files: ["/report.pdf"] });

Batch

const results = await sdk.sendBatch([
  { to: "+1234567890", content: "Hi Alice" },
  { to: "+0987654321", content: "Hi Bob" },
]);

for (const r of results) {
  if (r.success) console.log("Sent to", r.to);
  else console.error("Failed", r.to, r.error);
}
Concurrency is controlled by maxConcurrentSends from the SDK config.

Querying messages

const result = await sdk.getMessages({
  sender: "+1234567890",
  unreadOnly: true,
  limit: 20,
  since: new Date("2026-01-01"),
  search: "meeting",
});
Returns a . Each entry is a .
OptionTypeDescription
unreadOnlybooleanOnly query unread messages
excludeOwnMessagesbooleanExclude messages sent by current user (default: true)
senderstringFilter by sender
chatIdstringFilter by chat ID
serviceServiceTypeFilter by service type
hasAttachmentsbooleanOnly query messages with attachments
excludeReactionsbooleanExclude tapback reactions from results
sinceDateOnly query messages after this time
searchstringSearch message text content (case-insensitive)
limitnumberLimit number of results

Unread messages

getUnreadMessages() groups results by sender:
const unread = await sdk.getUnreadMessages();
console.log(`${unread.total} unread messages from ${unread.senderCount} senders`);

for (const group of unread.groups) {
  console.log(`${group.sender}: ${group.messages.length} messages`);
}

Listing chats

const all = await sdk.listChats();

const groups = await sdk.listChats({
  type: "group",
  hasUnread: true,
  sortBy: "recent",
  search: "Project",
  limit: 20,
});

for (const chat of groups) {
  console.log(chat.chatId, chat.displayName, chat.unreadCount);
}
Returns an array of .
OptionTypeDescription
limitnumberMaximum number of chats to return
type'all' | 'group' | 'dm'Filter by chat type
hasUnreadbooleanOnly return chats with unread messages
sortBy'recent' | 'name'Sort order
searchstringSearch by display name (case-insensitive)

Real-time watching

await sdk.startWatching({
  onMessage: (msg) => console.log(`New: ${msg.text}`),
  onDirectMessage: (msg) => console.log(`DM from ${msg.sender}`),
  onGroupMessage: (msg) => console.log(`Group: ${msg.chatId}`),
  onError: (err) => console.error(err),
});

// Later
sdk.stopWatching();
The watcher polls the Messages database and fires per registered callback — see for every available hook.

Auto-reply with the message chain

sdk.message(msg) returns a — a fluent builder for filter-then-respond patterns:
await sdk.startWatching({
  onDirectMessage: async (msg) => {
    await sdk.message(msg).ifFromOthers().matchText(/hello/i).replyText("Hi there!").execute();
  },
});
Chain guards to skip tapbacks, filter by chat kind, match on predicates, and choose a response:
await sdk
  .message(msg)
  .ifUnread()
  .ifNotReaction()
  .ifGroupChat()
  .when((m) => (m.sender ?? "").startsWith("+1"))
  .matchText(/photo/i)
  .replyImage(["/photo.jpg"])
  .execute();

Attachment helpers

import {
  attachmentExists,
  downloadAttachment,
  getAttachmentExtension,
  getAttachmentMetadata,
  getAttachmentSize,
  isAudioAttachment,
  isImageAttachment,
  isVideoAttachment,
  readAttachment,
} from "@photon-ai/imessage-kit";

const { messages } = await sdk.getMessages({ hasAttachments: true, limit: 1 });
const attachment = messages[0].attachments[0];

if (await attachmentExists(attachment)) {
  const size = await getAttachmentSize(attachment);
  const ext = getAttachmentExtension(attachment);

  if (isImageAttachment(attachment)) {
    // Stream or buffer the bytes
    const bytes = await readAttachment(attachment);
    await downloadAttachment(attachment, "/path/to/save.jpg");
  }
}
Each attachment is an .

Common media formats

Messages carries a wide range of formats through without transcoding. The SDK’s isImage / isVideo / isAudio helpers classify by the stored MIME type — there’s no allowlist.
CategoryTypical formats
DocumentsPDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, RTF
ImagesJPG, PNG, GIF, HEIC, WEBP, AVIF
ContactsVCF (vCard)
DataCSV, JSON, XML
ArchivesZIP, RAR, 7Z
MediaMP4, MOV, MP3, M4A

Scheduling

MessageScheduler

import { IMessageSDK, MessageScheduler } from "@photon-ai/imessage-kit";

const sdk = new IMessageSDK();
const scheduler = new MessageScheduler(
  sdk,
  { debug: true },
  {
    onSent: (task: any, result: any) => console.log(`Sent: ${task.id}`),
    onError: (task: any, error: Error) => console.error(`Failed: ${error.message}`),
    onComplete: (task: any) => console.log(`Completed: ${task.id}`),
  },
);

const id = scheduler.schedule({
  to: "+1234567890",
  content: "Reminder!",
  sendAt: new Date(Date.now() + 5 * 60_000),
});

scheduler.scheduleRecurring({
  to: "+1234567890",
  content: "Good morning!",
  startAt: new Date("2026-01-01T08:00:00"),
  interval: "daily",
  endAt: new Date("2026-12-31"),
});

scheduler.reschedule(id, new Date(Date.now() + 60_000));
scheduler.cancel(id);
scheduler.getPending();

scheduler.destroy();
The constructor takes (sdk, config?, events?) — three positional args. Config is ; one-shot task shape is and recurring is . The scheduler loop is internal — no start() call needed.

Reminders — natural-language scheduling

import { IMessageSDK, Reminders } from "@photon-ai/imessage-kit";

const sdk = new IMessageSDK();
const reminders = new Reminders(sdk);

reminders.in("5 minutes", "+1234567890", "Take a break!");
reminders.in("2 hours", "+1234567890", "Call the client");
reminders.at("5pm", "+1234567890", "End of day review");
reminders.at("tomorrow 9am", "+1234567890", "Morning standup");
reminders.exact(new Date("2026-12-25T10:00:00"), "+1234567890", "Merry Christmas!");

reminders.list();
reminders.cancel("reminder-id");
reminders.destroy();
All scheduling calls take an optional . Supported expressions:
  • Duration: "5 minutes", "2 hours", "1 day", "30 seconds", "1 week"
  • Time: "5pm", "5:30pm", "17:30"
  • Day + time: "tomorrow 9am", "friday 2pm"

Plugins

import { definePlugin, loggerPlugin } from "@photon-ai/imessage-kit";

sdk.use(loggerPlugin({ level: "info", colored: true }));

sdk.use(
  definePlugin({
    name: "my-plugin",
    onInit: async () => console.log("Initialized"),
    onBeforeSend: async (to, content) => console.log("Sending to:", to, content),
    onAfterSend: async (to, result) => console.log("Sent:", to, result),
    onDestroy: async () => console.log("Destroyed"),
  }),
);
See for the full hook surface. Hooks take positional args — onBeforeSend(to, content), onAfterSend(to, result) — not a single object. definePlugin() is a helper that just returns its argument for type inference.

Error handling

import {
  ConfigError,
  DatabaseError,
  IMessageError,
  PlatformError,
  SendError,
  WebhookError,
} from "@photon-ai/imessage-kit";

try {
  await sdk.send("+1234567890", "Hello");
} catch (error) {
  if (error instanceof IMessageError) {
    console.error(`[${error.code}] ${error.message}`);
  }
}
Every thrown error is an . The factory functions (PlatformError, DatabaseError, SendError, WebhookError, ConfigError) produce IMessageError instances tagged with the corresponding code — switch on error.code rather than instanceof for each subtype.