A full-featured iMessage SDK for reading, sending, and automating iMessage conversations on macOS. Perfect for building AI agents, automation tools, and chat-first applications.
Note
✨ Looking for advanced features like threaded replies, tapbacks, message editing, unsending, live typing indicators? Try Photon Spectrum
Photon builds infrastructure for AI agents that operate over real communication channels.
Spectrum is Photon’s open-source multi-channel agent framework, enabling AI agents to communicate through interfaces people already use—such as iMessage, SMS, email, Slack, Discord, and voice—instead of being confined to web chat.
| Feature | Method | Example |
|---|---|---|
| Send Text | sdk.send() |
01-send-text.ts |
| Send Image | sdk.send() |
02-send-image.ts |
| Send File | sdk.send() |
03-send-file.ts |
| Send to Group | sdk.send() |
04-send-group.ts |
| Query Messages | sdk.getMessages() |
05-query-messages.ts |
| List Chats | sdk.listChats() |
06-list-chats.ts |
| Real-time Watching | sdk.startWatching() |
07-watch-messages.ts |
| Auto Reply | onDirectMessage → sdk.send() |
08-auto-reply.ts |
| Plugin System | sdk.use() |
10-plugin.ts |
| Error Handling | IMessageError |
11-error-handling.ts |
# For Bun (zero dependencies)
bun add @photon-ai/imessage-kit
# For Node.js (requires better-sqlite3)
npm install @photon-ai/imessage-kit better-sqlite3import { IMessageSDK } from '@photon-ai/imessage-kit'
const sdk = new IMessageSDK()
// Send a text message
await sdk.send({ to: '+1234567890', text: 'Hello from iMessage Kit!' })
// Or use async-dispose to guarantee teardown:
await using disposable = new IMessageSDK()
await disposable.send({ to: '+1234567890', text: 'Hi!' })
// Manual teardown
await sdk.close()// Simplified; `readonly` modifiers omitted for readability — see src/types/config.ts
interface IMessageConfig {
databasePath?: string // Path to Messages SQLite database (default: ~/Library/Messages/chat.db)
maxConcurrentSends?: number // Concurrent send cap (default 10, range 1..50)
sendTimeout?: number // ms per AppleScript invocation (default 30_000, range 1_000..300_000)
debug?: boolean // Verbose SDK logs
plugins?: Plugin[] // Plugins registered at construction; sdk.use() is also available later
}Out-of-range numeric values throw IMessageError(code: 'CONFIG') at construction — they are not silently clamped. The accepted ranges are exposed as the BOUNDS constant exported from the package root.
IMessageKit requires Full Disk Access to read chat.db.
- Open System Settings → Privacy & Security → Full Disk Access
- Click "+" and add your IDE or terminal (e.g., Cursor, VS Code, Terminal, Warp)
sdk.send(request)returnsPromise<void>that resolves whenosascriptexits successfully. It does not confirm the message landed inchat.db, nor does it return aMessageobject.- To correlate your send with a
chat.dbrow (and observe delivery transitions), subscribe toonFromMeMessagevia the watcher — it fires for every from-me row observed, whether authored by this SDK, another Apple client, or Messages.app.
// Fire-and-forget send
await sdk.send({ to: '+1234567890', text: 'Hi' })
// Observe the landed row
await sdk.startWatching({
onFromMeMessage: (msg) => console.log('Landed in chat.db:', msg.id, msg.isDelivered),
})Examples: 01-send-text.ts | 02-send-image.ts | 03-send-file.ts | 05-query-messages.ts
sdk.send(request: SendRequest): Promise<void>
// Simplified; `readonly` modifiers omitted for readability — see src/types/send.ts
interface SendRequest {
to: string // phone, email, or chatId
text?: string
attachments?: string[] // local absolute paths; remote URLs are rejected
}
// Text
await sdk.send({ to: '+1234567890', text: 'Hello World!' })
// Email recipient
await sdk.send({ to: 'user@example.com', text: 'Hello!' })// Local file paths only — download remote URLs yourself first.
await sdk.send({ to: '+1234567890', attachments: ['/abs/path/image.jpg'] })
// Text + multiple attachments — non-transactional: the first osascript call
// bundles text + attachments[0]; each later attachment is its own call with
// a ~500ms inter-step pacing. A mid-batch failure is labelled
// "attachment N/total".
await sdk.send({
to: '+1234567890',
text: 'Check this out',
attachments: ['/abs/path/photo.jpg', '/abs/path/report.pdf']
})const messages = await sdk.getMessages({
chatId: 'any;+;chat534ce85d...', // optional — scopes to one conversation
participant: '+1234567890',
service: 'iMessage', // 'iMessage' | 'SMS' | 'RCS'
isFromMe: false, // tri-state: omit → both
isRead: false, // tri-state: omit → both
hasAttachments: true, // tri-state: omit → both
excludeReactions: true, // drop tapback/sticker rows
since: new Date('2025-01-01'),
before: new Date('2025-02-01'),
search: 'meeting', // app-layer substring over decoded text
limit: 20,
offset: 0,
})search runs in application layer over decoded attributedBody — there is no SQL LIKE index. Narrow with chatId / participant / since / limit on large databases.
Examples: 04-send-group.ts | 06-list-chats.ts
const chats = await sdk.listChats({
chatId: 'any;+;chat...', // optional — scope to one chat
kind: 'group', // 'group' | 'dm'
service: 'iMessage',
isArchived: false,
hasUnread: true,
sortBy: 'recent', // 'recent' | 'name'
search: 'Project', // LIKE over display_name / chat_identifier (escaped)
limit: 20,
offset: 0,
})
for (const chat of chats) {
console.log({
chatId: chat.chatId,
name: chat.name,
kind: chat.kind,
unread: chat.unreadCount,
lastMessageAt: chat.lastMessageAt,
})
}Never hand-write a group chatId. Always use one surfaced by the SDK.
// From listChats
const groups = await sdk.listChats({ kind: 'group' })
await sdk.send({ to: groups[0].chatId, text: 'Hello group!' })
// From the watcher
await sdk.startWatching({
onGroupMessage: async (msg) => {
if (msg.chatId) await sdk.send({ to: msg.chatId, text: 'ack' })
}
})| Format | Example | Used for |
|---|---|---|
| DM bare address | +1234567890 / user@example.com |
DM routing; SDK prefixes internally |
| DM prefixed | iMessage;-;+1234567890 |
Canonical DM chatId |
| Group (macOS 26+) | any;+;chat534ce85d... |
Group chat (current) |
| Group (legacy) | iMessage;+;chat534ce85d... |
Pre-macOS-26 group chat |
| Group (bare GUID) | chat45e2b868... |
Accepted as input; SDK prefixes internally |
Parse / validate directly via the exported value object when needed:
import { ChatId, resolveTarget } from '@photon-ai/imessage-kit'
const cid = ChatId.fromUserInput('iMessage;-;pilot@photon.codes')
cid.isGroup // false
cid.coreIdentifier // 'pilot@photon.codes'
const target = resolveTarget('+1234567890') // MessageTarget (dm | group)Examples: 07-watch-messages.ts | 08-auto-reply.ts | 09-get-sent-message.ts
sdk.startWatching(events) accepts five callbacks. Calling it while a watcher is already running throws IMessageError(code: 'CONFIG', message: 'Watcher is already running') — stop it first.
await sdk.startWatching({
onIncomingMessage: (msg) => { /* every incoming (non-from-me) row */ },
onDirectMessage: (msg) => { /* incoming DMs only */ },
onGroupMessage: (msg) => { /* incoming group messages only */ },
onFromMeMessage: (msg) => { /* any from-me row — this SDK or another client */ },
onError: (err) => { /* dispatch errors */ },
})
await sdk.stopWatching() // safe to call even if never startedawait sdk.startWatching({
onDirectMessage: async (msg) => {
if (!msg.text || !/hello/i.test(msg.text)) return
if (!msg.chatId) return // rare WAL race before chat_message_join flushes
await sdk.send({ to: msg.chatId, text: 'Hi there!' })
}
})Examples: 02-send-image.ts | 03-send-file.ts
Only iMessage-specific helpers are exported. For copy / read / stat, use node:fs directly against attachment.localPath.
import {
attachmentExists,
getAttachmentExtension,
isImageAttachment,
isVideoAttachment,
isAudioAttachment,
} from '@photon-ai/imessage-kit'
const [msg] = await sdk.getMessages({ hasAttachments: true, limit: 1 })
const attachment = msg?.attachments[0]
if (attachment && await attachmentExists(attachment)) {
if (isImageAttachment(attachment)) {
const ext = getAttachmentExtension(attachment) // lowercase, no leading dot — e.g. 'jpg'
// Use node:fs for anything further (copyFile, createReadStream, stat, …)
}
}Example: 10-plugin.ts · reference logger: logger-plugin.ts
sdk.use(plugin) can be called before or after sdk is initialized — late registrations are joined to the pipeline on the next hook. Plugins are torn down on sdk.close().
import { definePlugin } from '@photon-ai/imessage-kit'
const audit = definePlugin({
name: 'audit',
version: '1.0.0',
onBeforeSend: ({ request }) => {
// Throw here to veto the send; cause is attached to IMessageError(SEND).
if (request.text?.includes('forbidden')) throw new Error('blocked by policy')
},
onAfterSend: ({ request }) => {
console.log('[audit] dispatched to', request.to)
},
})
sdk.use(audit)All 11 hooks, grouped by dispatch mode:
| Hook | Mode | Behaviour on throw |
|---|---|---|
onInit |
sequential | Routed to onError |
onDestroy |
sequential | Routed to onError |
onError |
sequential | Logged once; not re-routed (prevents recursion) |
onBeforeMessageQuery |
interrupting | Aborts getMessages with IMessageError(DATABASE) |
onBeforeChatQuery |
interrupting | Aborts listChats with IMessageError(DATABASE) |
onBeforeSend |
interrupting | Aborts send with IMessageError(SEND) — use as auth/policy gate |
onAfterMessageQuery |
parallel | Routed to onError |
onAfterChatQuery |
parallel | Routed to onError |
onAfterSend |
parallel | Fires only on successful AppleScript dispatch |
onIncomingMessage |
parallel | Every incoming row observed by the watcher |
onFromMe |
parallel | Every from-me row observed — authoritative DB-arrival signal |
Naming quirk. The same from-me event surfaces as
DispatchEvents.onFromMeMessage(user callback passed tostartWatching) andPluginHooks.onFromMe(plugin entry point). They are intentionally distinct to mark the "inline handler" vs "plugin observer" boundary.
Example: 11-error-handling.ts
All SDK failures surface as IMessageError with a typed code.
import { IMessageError } from '@photon-ai/imessage-kit'
try {
await sdk.send({ to: '+1234567890', text: 'Hello' })
} catch (error) {
if (error instanceof IMessageError) {
// error.code: 'PLATFORM' | 'DATABASE' | 'SEND' | 'CONFIG'
// error.cause: original thrown Error (when applicable)
console.error(`[${error.code}] ${error.message}`)
}
}IMessageError codes map to failure classes:
PLATFORM— non-darwin runtime, or missing$HOME(only raised byrequireMacOS()/getDefaultDatabasePath())DATABASE— SQLite open failure, query errors, decoder issues, oronBeforeMessageQuery/onBeforeChatQueryplugin vetoSEND— AppleScript dispatch failure,osascriptnon-zero exit, Messages.app not running, attachment unreadable, send cancellation, oronBeforeSendplugin vetoCONFIG— out-of-bounds config, malformed chatId, SDK already destroyed, watcher already running, duplicate plugin name
Run any example with Bun (requires macOS and Full Disk Access):
bun run examples/01-send-text.ts- 01-send-text.ts — basic text message
- 02-send-image.ts — send an image attachment
- 03-send-file.ts — send an arbitrary file
- 05-query-messages.ts — filter history
- 09-get-sent-message.ts — correlate a send with its chat.db row
- 04-send-group.ts — send to a group
- 06-list-chats.ts — list conversations
- 07-watch-messages.ts — watcher lifecycle
- 08-auto-reply.ts — auto-reply bot
- 10-plugin.ts — custom plugin
- 11-error-handling.ts —
IMessageErrorhandling - logger-plugin.ts — a reference logger plugin to adapt
| Method | Description |
|---|---|
new IMessageSDK(config?) |
Construct the SDK (sync). Opens the DB lazily. |
sdk.use(plugin) |
Register a plugin; valid before or after init. |
sdk.getMessages(query?) |
Query historical messages. Returns Message[]. |
sdk.listChats(query?) |
Query chat summaries. Returns Chat[]. |
sdk.send(request) |
Dispatch a send via AppleScript. Resolves on osascript exit. |
sdk.startWatching(events) |
Begin WAL-based real-time watching. Throws IMessageError(CONFIG) if a watcher is already live. |
sdk.stopWatching() |
Stop the watcher. Safe when never started. |
sdk.close() |
Tear down watcher, plugins, and DB. Concurrent callers share the in-flight teardown; teardown failures surface as AggregateError. |
await using sdk = new IMessageSDK() |
Symbol.asyncDispose integration — auto-close on scope exit. |
interface Message {
rowId: number
id: string
text: string | null
participant: string | null
chatId: string | null
chatKind: 'dm' | 'group' | 'unknown'
service: 'iMessage' | 'SMS' | 'RCS' | null
kind: 'text' | 'memberAdded' | 'memberRemoved' | 'nameChanged' | 'groupAction' | 'unknown'
isFromMe: boolean
isRead: boolean
isSent: boolean
isDelivered: boolean
createdAt: Date
deliveredAt: Date | null
readAt: Date | null
editedAt: Date | null
retractedAt: Date | null
reaction: Reaction | null
attachments: Attachment[]
// ...plus ~30 additional fields; see src/domain/message.ts for the full interface
}Full types — Message, Chat, Attachment, Reaction, SendRequest, MessageQuery, ChatQuery, Plugin, PluginHooks, DispatchEvents, MessageTarget — are exported from the package root. See llms.txt for the condensed reference.
- OS: macOS only
- Runtime: Node.js >= 20.0.0 or Bun >= 1.0.0
- Permissions: Full Disk Access
Download llms.txt for language model context:
Add Context7 MCP to your IDE, then use:
use context7: photon-hq/imessage-kit
Note: This SDK is for educational and development purposes. Always respect user privacy and follow Apple's terms of service.
