npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@photon-ai/imessage-kit

v3.0.0

Published

Type-safe macOS iMessage SDK for TypeScript

Readme

Banner

@photon-ai/imessage-kit

A type-safe, elegant iMessage SDK for macOS with cross-runtime support

npm version TypeScript License Discord

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 [email protected].


Features

| 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 | onDirectMessagesdk.send() | 08-auto-reply.ts | | Plugin System | sdk.use() | 10-plugin.ts | | Error Handling | IMessageError | 11-error-handling.ts |


Quick Start

Installation

# For Bun (zero dependencies)
bun add @photon-ai/imessage-kit

# For Node.js (requires better-sqlite3)
npm install @photon-ai/imessage-kit better-sqlite3

Basic Usage

import { 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()

Configuration

// 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.

Granting Permission

IMessageKit requires Full Disk Access to read chat.db.

  1. Open System Settings → Privacy & Security → Full Disk Access
  2. Click "+" and add your IDE or terminal (e.g., Cursor, VS Code, Terminal, Warp)

Send vs Observe Semantics

  • sdk.send(request) returns Promise<void> that resolves when osascript exits successfully. It does not confirm the message landed in chat.db, nor does it return a Message object.
  • To correlate your send with a chat.db row (and observe delivery transitions), subscribe to onFromMeMessage via 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),
})

Messages

Examples: 01-send-text.ts | 02-send-image.ts | 03-send-file.ts | 05-query-messages.ts

Send Messages

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: '[email protected]', text: 'Hello!' })

Send Attachments

// 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']
})

Query Messages

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.


Chats

Examples: 04-send-group.ts | 06-list-chats.ts

List Chats

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,
    })
}

Send to Groups

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' })
    }
})

ChatId Formats

| Format | Example | Used for | |--------|---------|----------| | DM bare address | +1234567890 / [email protected] | 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;-;[email protected]')
cid.isGroup            // false
cid.coreIdentifier     // '[email protected]'

const target = resolveTarget('+1234567890')   // MessageTarget (dm | group)

Real-time Events

Examples: 07-watch-messages.ts | 08-auto-reply.ts | 09-get-sent-message.ts

Real-time Watching

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 started

Auto Reply

await 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!' })
    }
})

Attachments

Examples: 02-send-image.ts | 03-send-file.ts

Attachment Helpers

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, …)
    }
}

Plugin System

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)

Hook contract

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 to startWatching) and PluginHooks.onFromMe (plugin entry point). They are intentionally distinct to mark the "inline handler" vs "plugin observer" boundary.


Error Handling

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 by requireMacOS() / getDefaultDatabasePath())
  • DATABASE — SQLite open failure, query errors, decoder issues, or onBeforeMessageQuery / onBeforeChatQuery plugin veto
  • SEND — AppleScript dispatch failure, osascript non-zero exit, Messages.app not running, attachment unreadable, send cancellation, or onBeforeSend plugin veto
  • CONFIG — out-of-bounds config, malformed chatId, SDK already destroyed, watcher already running, duplicate plugin name

Examples

Run any example with Bun (requires macOS and Full Disk Access):

bun run examples/01-send-text.ts

Getting Started

Message Operations

Chats & Groups

Real-time & Automation

Advanced


API Reference

Core Methods

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

Types

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.


Requirements

  • OS: macOS only
  • Runtime: Node.js >= 20.0.0 or Bun >= 1.0.0
  • Permissions: Full Disk Access

LLMs

Download llms.txt for language model context:

Context7 MCP

Add Context7 MCP to your IDE, then use:

use context7: photon-hq/imessage-kit

License

MIT License


Note: This SDK is for educational and development purposes. Always respect user privacy and follow Apple's terms of service.