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? Check out Advanced iMessage Kit and contact us at daniel@photon.codes.
| 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.
