[BREAKGLASS] CLI for Apple's Messages.app so your agent can send and receive text messages/iMessages. https://imsg.to
Go to file
Omar Shahine 2d7b506d17
fix: macOS 26 bridge regressions — typing/read RPC, effect mapping, attachment registration (#101)
* feat: file-based debug logger for in-process diagnostics

NSLog output emitted from the injected dylib is redacted by macOS 26
unified logging when the host process is a system app, which makes
diagnosing handler behavior from outside the dylib painful (you can't
see anything in Console.app, log show, or the gateway logs).

Add an append-only file logger writing to .imsg-bridge.log in the
Messages.app sandbox container. Readable from outside, untouched by
unified logging.

Wire diagnostic entry/exit/state logs through the typing and read
handlers so future regressions in setLocalUserIsTyping: /
markAllMessagesAsRead behavior across macOS versions can be triaged
from log output rather than guesswork.

* fix(rpc): expose typing and read methods over JSON-RPC

The 'imsg rpc' server only routed chats.list, messages.history,
watch.subscribe, watch.unsubscribe, and send. Calls to 'typing' and
'read' (which the openclaw imessage channel plugin and other clients
already invoke per the documented vocabulary) returned methodNotFound
and silently dropped. The CLI worked because it talks to the dylib
bridge directly via TypingIndicator/IMCoreBridge, bypassing the RPC
surface entirely.

Wire both methods to the same chat-target resolution path used by
'send', so callers can identify the chat by handle (to), chat_id,
chat_identifier, or chat_guid. typing accepts a 'typing' bool plus
optional 'service'; read takes only the chat target.

The dylib-side handlers (handleTyping, handleRead) were already
correct — they just had no way to be invoked over JSON-RPC.

* fix: expand --effect short names to expressive-send bundle IDs

`imsg send-rich --effect invisibleink` was passing the literal short name
through to expressiveSendStyleID, so chat.db ended up with
expressive_send_style_id=invisibleink and Messages.app refused to render
the expressive effect. Messages expects the full bundle id, e.g.
com.apple.MobileSMS.expressivesend.invisibleink for bubble effects or
com.apple.messages.effect.CKConfettiEffect for screen effects.

Add ExpressiveSendEffect.expand() to remap short names at the CLI layer
for both send-rich and send-multipart, leave already-prefixed values
untouched, and pass unknown names through so the dylib can return its
own error. Update --help examples to use the friendlier short names and
add unit coverage.

* fix: register outgoing transfers properly in handleSendAttachment

`imsg send-attachment` was returning a transferGuid and messageGuid but
the receiver saw an empty OBJ-placeholder message and chat.db's
attachment / message_attachment_join tables had no matching rows. Two
gaps versus the BlueBubblesHelper reference:

1. The transfer was never staged into Messages' attachments tree or
   handed off to imagent. Allocating a guid via
   `guidForNewOutgoingTransferWithLocalURL:` only reserves the id; the
   daemon needs `IMDPersistentAttachmentController._persistentPath...`
   plus `retargetTransfer:toPath:` and `registerTransferWithDaemon:`
   before it will persist the attachment row.
2. The IMMessage body was a bare `` placeholder with no IM
   attributes, so even after registration Messages could not link the
   attachment to the message part. Add
   `__kIMFileTransferGUIDAttributeName`, `__kIMFilenameAttributeName`,
   `__kIMMessagePartAttributeName`, and
   `__kIMBaseWritingDirectionAttributeName` to the placeholder run.

Factor the staging path into `prepareOutgoingTransfer` for readability
and so future multipart attachment work can reuse it. Tighten the
IMFileTransfer / IMFileTransferCenter forward declarations and add the
IMDPersistentAttachmentController interface.

* fix(rpc): advertise rpc_methods capability in status --json

The openclaw imessage channel plugin reads `rpc_methods` from
`imsg status --json` to gate which JSON-RPC calls it issues. Older
imsg builds don't ship this field, so consumers fall back to a small
foundational set (chats.list/messages.history/watch.*/send) and refuse
to invoke typing/read/group.* until the user upgrades.

The previous commit added `typing` and `read` handlers to RPCServer
but didn't advertise them, so the openclaw plugin still gated them off
with the message:

  imessage: typing indicators / read receipts gated off (imsg build
  pre-dates the rpc_methods capability list). Upgrade imsg (current
  bridge needs typing+read in rpc_methods).

Add `rpc_methods` to StatusPayload, sourced from a top-level constant
`kSupportedRPCMethods` in RPCServer.swift so the dispatch switch and
the advertised list can't drift apart.

* fix: build IMMessageItem first to survive macOS 26 send pipeline

On macOS 26 the high-level `+initIMMessageWith…:expressiveSendStyleID:`
factories return an IMMessage whose underlying IMMessageItem has empty
`bodyData`. imagent reads bodyData (NSArchiver typedstream) when
shipping to chat.db; an empty payload gets silently dropped, so every
`imsg send-rich` call returned 'Could not construct IMMessage' and any
bridge-routed send was a no-op. Lobster and other JSON-RPC consumers
that use send-rich/send-multipart inherit the failure.

Port 10ce6ab's IMMessageItem-first approach into buildIMMessage:

1. Allocate an IMMessageItem via the 9-arg
   `initWithSender:time:body:attributes:fileTransferGUIDs🎏error:guid:threadIdentifier:`
2. NSArchiver-archive the attributed body and `setBodyData:` it onto
   the item (the daemon reads bodyData, not body). Fall back to a
   plain-text retry if NSPresentationIntent breaks the archive.
3. Apply the item-level extended fields (expressiveSendStyleID,
   subject, associatedMessageGUID/Type/Range, summaryInfo) via setters
   BEFORE wrapping (post-wrap _imMessageItem returns a transient item
   whose setters don't persist).
4. Wrap with `+[IMMessage messageFromIMMessageItem:sender:subject:]`.
5. Dispatch via `-[IMChat _sendMessage:adjustingSender:shouldQueue:]`
   — public sendMessage: silently no-ops on items with sender = nil
   on macOS 26.

The legacy `initIMMessageWithSender:…:expressiveSendStyleID:` path is
preserved as a fallback for older OSes that don't expose the modern
item-construction selectors.

Smoke-tested on macOS 26.4.1: send-rich now lands in chat.db with
attributedBody populated and expressive_send_style_id correctly set.

Known follow-ups (existing failure modes, not introduced here):
- reply threading via selectedMessageGuid stores no thread_originator
  link; needs `IMCreateThreadIdentifierForMessagePartChatItem`-derived
  threadIdentifier.
- tapback (reaction) handler still routes through buildIMMessage; the
  associated-message fields don't survive the IMMessageItem 9-arg init.
  A dedicated reaction constructor is the right fix.

* fix: derive thread identifier for replies + dedicated reaction constructor

Replies via `selectedMessageGuid` previously sent as standalone
messages on macOS 26 because the receiver also needs the
`threadIdentifier` string to render the in-line reply UI; the
associated_message_guid + type=100 combination alone isn't sufficient
on Tahoe. Load the parent message via
`-[IMChatHistoryController loadMessageWithGUID:completionBlock:]`,
walk to its first IMMessagePartChatItem, and call the IMCore C
function `IMCreateThreadIdentifierForMessagePartChatItem` (resolved
via dlsym since the symbol lives only in the dyld shared cache on
macOS 26) to derive the canonical thread id. Set both
setThreadIdentifier: and setThreadOriginator: on the wrapped IMMessage.

Tapbacks: replace the buildIMMessage path with the dedicated
`+[IMMessage instantMessageWithAssociatedMessageContent:associatedMessageGUID:associatedMessageType:associatedMessageRange:associatedMessageEmoji:messageSummaryInfo:threadIdentifier:]`
class method, prefixing the parent guid with `p:<part>/` to match
iMessage's canonical part-targeted reference format
(`p:0/<parent-guid>`). Seed the underlying IMMessageItem's bodyData
manually so imagent has a payload to ship.

Smoke-tested on macOS 26.4.1: replies now persist with
thread_originator_guid pointing back at the parent, and the
expressive_send_style_id from the previous fix continues to land
correctly. Tapback persistence in chat.db remains finicky on this
particular Messages session — visible behavior on the receiver still
needs human verification.

* fix(react): route reactions through legacy initIMMessageWithSender:…:associatedMessageGUID: path

The IMMessageItem-first path doesn't preserve associated-message
fields (the 9-arg item initializer doesn't accept them, and post-init
setters don't survive the IMMessage wrap on macOS 26 — verified by
tapbacks dispatching cleanly but not landing in chat.db).

Skip the IMMessageItem-first short-circuit when associatedMessageGuid
+ associatedMessageType > 0 are set, falling through to the long
initIMMessageWith…:associatedMessageGUID:… initializer that takes all
reaction metadata atomically. Mirror what upstream's reaction path was
doing at chat.db row 5078 in the dev-machine smoke test (the last
successful outgoing tapback before the macOS 26 regression cluster).

Use the public sendMessage: dispatch for reactions —
_sendMessage:adjustingSender:shouldQueue: appears to interfere with
the reaction-message flow on macOS 26 even when the public path works
elsewhere. handleSendReaction also adds the canonical p:<part>/<guid>
prefix to associatedMessageGUID, matching the format chat.db stores
for working tapbacks (was 'raw guid' before; iMessage's part-targeted
reference format is mandatory for the receiver to render the heart).

Smoke-tested on macOS 26.4.1 post-reboot: dispatches without exception
but doesn't always persist in chat.db on this Messages session. Marked
as known follow-up — the reaction selector path on macOS 26 likely
needs further reverse-engineering (a class-dump pass on IMMessage and
IMMessageItem to find the modern reaction constructor).

* fix: BlueBubblesHelper-verified macOS 26 selectors + reaction body

After a fine-tooth audit against BlueBubblesHelper's macOS-11+ tree,
several deltas in our IMCore use were causing macOS 26 regressions:

1. Reaction init signature was wrong on macOS 26.
   Use IMMessage's 13-arg `initWithSender:time:text:messageSubject:fileTransferGUIDs🎏error:guid:subject:associatedMessageGUID:associatedMessageType:associatedMessageRange:messageSummaryInfo:`
   (the BB-verified macOS 26 selector — no balloonBundleID/payloadData/
   expressiveSendStyleID args). The 17-arg `initIMMessageWith…` we were
   using doesn't exist on macOS 26 (instancesRespondToSelector returns
   NO), which is why every tapback returned 'Could not build reaction
   IMMessage' or silently no-op'd.

2. Reaction body was empty — imagent silently dropped reactions.
   Reactions need a verb-style attributedBody (`Loved "parent text"`)
   not an empty string. Mirror BB's reactionToVerb mapping for
   love/like/dislike/laugh/emphasize/question and their remove-* forms.
   Best-effort load the parent message via deriveThreadIdentifier (which
   we already had wired up for replies) so we have its text to quote;
   fall back to a generic `Loved a message` phrase if the parent can't
   be resolved.

3. Reaction associatedMessageGUID needs the `p:<part>/<guid>` prefix.
   The receiver pipeline ignores reaction messages whose
   associatedMessageGUID is a bare guid (no part prefix). chat.db rows
   for working tapbacks (e.g. row 5078 in the dev-machine smoke test)
   show `p:0/<parent-guid>`.

4. Send/attachment flags were wrong — 0x5 instead of 0x100005.
   The 0x100000 bit is what tells imagent to finalize the payload (vs
   treating the item as a non-finalized internal staging record). With
   0x5 the message dispatched but the receiver got a malformed
   attachment; with 0x100005 the daemon properly finalizes.
   BB-verified: isAudioMessage ? 0x300005 : (subject ? 0x10000d : 0x100005).

5. Send init signature: prefer BB's 12-arg `initWithSender:…:expressiveSendStyleID:`
   over the legacy 12-arg `initIMMessageWithSender:` form (same args,
   different prefix). Fall through to the legacy form if the macOS 26
   selector isn't available.

6. Use 2-step init (`[[IMMessage alloc] init]` then re-init) for
   reactions, matching BB's pattern. The single-step alloc + invocation
   pattern can leave the message partially deallocated under macOS 26's
   stricter ARC.

Smoke-tested on macOS 26.4.1: tapbacks now persist in chat.db with the
correct associated_message_guid + type, and the heart renders on the
iPhone. send-attachment still has a separate IMFileTransfer-side
registration gap that doesn't link the attachment row in chat.db, but
the receiver-visible behavior should be correct with the new flags.

* fix(attachment): tighten ARC retention + skip IMMessageItem-first

Per the BB-helper audit:

1. `_persistentPathForTransfer:…` returns its NSString via
   NSInvocation.getReturnValue, which puts the result into an
   __unsafe_unretained slot. Under macOS 26's stricter ARC the
   returned string can be released before we copy the file, leaving
   prepareOutgoingTransfer with a zombie pointer or nil. Take a
   strong reference immediately after getReturnValue.

2. Attachments shouldn't go through the IMMessageItem-first path —
   BB-helper builds attachment messages via the regular IMMessage
   `initWithSender:…:expressiveSendStyleID:` initializer, which
   handles fileTransferGUIDs natively and finalizes the payload
   correctly with the 0x100005 flag set. Routing through the
   IMMessageItem 9-arg init left the transfer registered but the
   payload unfinalized in some macOS 26 states.

Smoke-tested on macOS 26.4.1: prepareOutgoingTransfer's persistentPath
diagnostic still logs `(nil)` on this machine — the
IMDPersistentAttachmentController._persistentPathForTransfer:… selector
is exposed but returns nil for our IMFileTransfer object, which is a
deeper macOS 26 staging-API change that needs separate investigation
(possibly _saveAttachmentForTransfer:highQuality:copyWithinAttachmentStore:chatGUID:storeAtExternalPath:
is the modern entry point). Documenting as a known follow-up; the
AppleScript path (imsg send --file) remains a reliable workaround.

* fix: BlueBubblesHelper-aligned selectors for group ops, mark-unread, notify-anyways, reaction summary

Six independent functional bugs the BB-helper audit surfaced; all
single-selector fixes that bring our IMCore handling back in line with
the canonical BB MacOS-11+ tree.

1. Add participant: selector was `addParticipantsToiMessageChat:reason:`
   (not declared on IMChat). Use BB-verified
   `inviteParticipantsToiMessageChat:reason:`. Group join was
   error-failing.

2. Set chat display name: `setDisplayName:` is just the public KVO
   setter (local-only mutation that doesn't post the IDS update). BB
   uses `_setDisplayName:` (underscore-prefixed) for the daemon-aware
   path that propagates to all chat members. Renames were sender-only
   before. Also fixed the second call site in handleCreateChat.

3. Update group photo: was `setGroupPhotoData:` with raw NSData (not
   declared on IMChat). BB stages the image via the file-transfer
   pipeline (prepareOutgoingTransfer) and calls
   `sendGroupPhotoUpdate:transferGUID`. Group photo was no-op'ing.

4. Notify-anyways: was wired to
   `sendMessageAcknowledgment:forChatItem:withMessageSummaryInfo:withGuid:`
   with ack=1000 — that's a tapback ack, not a notify-anyway. BB-verified
   selector is `markChatItemAsNotifyRecipient:` (single arg).
   notify-anyways wasn't actually bypassing focus mode before.

5. Mark chat unread: `setUnreadCount:1` only mutates a local KVO
   counter that doesn't sync to chat.db or propagate. BB uses
   `markLastMessageAsUnread` for the daemon-aware path.

6. Edit / unsend / delete-message lookup: `findMessageItem` walked
   `chat.chatItems` synchronously, which only covers the live IMChat
   window. Edit-after-scroll-back failed with 'Message not found'. Try
   `IMChatHistoryController.loadMessageWithGUID:completionBlock:` first
   (BB-verified macOS 11+ path), fall back to the sync walk for older
   OSes. Reuses the loadParentFirstChatItem helper.

7. Reaction `messageSummaryInfo` shape: `amc` was the parent guid as
   a string. BB sets `amc: @1` (NSNumber count). Wrong type made the
   bplist-encoded summary malformed and imagent dropped reactions on
   macOS 26 even with all other fields right. `ams` continues to carry
   the parent text for the receiver-side notification preview.

8. Reaction `associatedMessageRange`: was hardcoded `{0, 1}`. BB
   derives from the parent's first chat item via `messagePartRange`,
   which lets tapbacks target non-zero parts (e.g. the second image of
   a multipart photo grid). Loaded via the new loadParentFirstChatItem
   helper.

9. Send dispatch: `dispatchIMMessageInChat` preferred the private
   `_sendMessage:adjustingSender:shouldQueue:` over public `sendMessage:`.
   BB never touches the private path — every text/attachment/reaction/reply
   goes through public `sendMessage:`. The private selector signature
   may have shifted on macOS 26 in ways that drop edge-case items.
   Simplified dispatchIMMessageInChat to just call `[chat sendMessage:]`.

Smoke-tested on macOS 26.4.1 post-reboot: parent + tapback both
persist with the corrected message_summary_info shape (`amc=@1`,
`ams=parent text`); no regression in sends, effects, replies, or
typing/read.

* fix(rpc): restore chat/group lifecycle handlers cut during PR #100 merge

PR #100 was squash-merged with 8 RPC handler functions silently dropped
from the dispatch surface plus the corresponding `supportedMethods`
capability list. This commit reinstates them so the openclaw imessage
channel plugin (and any other JSON-RPC consumer) can call them again
without hitting methodNotFound.

Restored RPC methods:
- chats.create   — create a 1:1 or group chat
- chats.delete   — delete a chat from Messages.app
- chats.markUnread — mark last message in a chat as unread
- group.rename   — set a group chat's display name
- group.setIcon  — set or clear a group chat photo
- group.addParticipant
- group.removeParticipant
- group.leave    — leave a group chat

The handler functions all dispatch into v2 bridge actions the dylib
already implements (createChat, deleteChat, markChatUnread, leaveChat,
etc.), so no IMCore-side work is needed beyond what's in this PR's
existing dylib commits. Each method is also added to the
`kSupportedRPCMethods` list advertised via `imsg status --json`'s
`rpc_methods` field.

The handler implementations come from the original PR #100 branch
(`omarshahine:feat/private-api-port` at 638efbd), which had them in a
dedicated `RPCServer+ChatHandlers.swift` extension. Restored verbatim
since they reference the surviving `invokeBridge` / BridgeAction enum
infrastructure.

Verified `imsg status --json` now lists 15 methods (was 7), and each
restored case routes through to the correct BridgeAction.

* docs: note macOS 26 bridge fixes

---------

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-06 22:07:28 +01:00
.agents/skills/imsg feat: add advanced message controls 2026-04-27 02:11:23 +01:00
.github/workflows docs: add per-feature docs site and deploy to imsg.sh 2026-05-05 19:09:09 +01:00
docs docs: add per-feature docs site and deploy to imsg.sh 2026-05-05 19:09:09 +01:00
Resources feat: resolve contact names 2026-05-04 09:16:44 +01:00
scripts docs: add docs syntax highlighting 2026-05-06 06:34:55 +01:00
Sources fix: macOS 26 bridge regressions — typing/read RPC, effect mapping, attachment registration (#101) 2026-05-06 22:07:28 +01:00
Tests fix: macOS 26 bridge regressions — typing/read RPC, effect mapping, attachment registration (#101) 2026-05-06 22:07:28 +01:00
.gitignore docs: add per-feature docs site and deploy to imsg.sh 2026-05-05 19:09:09 +01:00
.swiftlint.yml feat: swift 6 rewrite 2025-12-28 17:17:40 +01:00
AGENTS.md chore: replace pnpm with make 2026-01-03 06:31:55 +01:00
CHANGELOG.md fix: macOS 26 bridge regressions — typing/read RPC, effect mapping, attachment registration (#101) 2026-05-06 22:07:28 +01:00
LICENSE chore: fix copyright header 2026-04-27 11:26:47 +01:00
Makefile docs: add per-feature docs site and deploy to imsg.sh 2026-05-05 19:09:09 +01:00
Package.swift feat: port BlueBubbles private-API bridge 2026-05-06 06:28:00 +01:00
README.md feat: port BlueBubbles private-API bridge 2026-05-06 06:28:00 +01:00
version.env chore: start 0.7.2 development 2026-05-06 08:35:49 +01:00

imsg

imsg is a macOS command-line tool for Messages.app. It reads your local Messages database, streams new iMessage/SMS rows, sends messages through Messages.app automation, and exposes the same surfaces over JSON and JSON-RPC.

Most read workflows need only Full Disk Access. Sending and standard tapbacks also need macOS Automation permission for Messages.app. Advanced IMCore features such as read receipts, typing indicators, and injection status are opt-in and are increasingly limited by macOS 26.

Highlights

  • Read recent chats and message history without modifying chat.db.
  • Stream new messages with watch, including a fallback poll when macOS misses file events.
  • Send text and files through Messages.app AppleScript, without private send APIs.
  • Inspect direct chats and groups, including participants, GUIDs, service, and account routing hints.
  • Emit newline-delimited JSON for automation, agents, and scripts.
  • Resolve Contacts names when permission is granted, while keeping raw handles in the output.
  • Report attachment metadata, and optionally expose model-compatible converted receive-side CAF/GIF files.
  • Use JSON-RPC over stdio for long-running integrations.

Requirements

  • macOS 14 or newer.
  • Messages.app signed in to iMessage and/or SMS relay.
  • Full Disk Access for the terminal or parent app that launches imsg.
  • Automation permission for Messages.app when using send or react.
  • Optional Contacts permission for name resolution.
  • Optional ffmpeg on PATH for receive-side attachment conversion.

For SMS, enable Text Message Forwarding on your iPhone for this Mac.

Install

brew install steipete/tap/imsg

Build from source:

make build
./bin/imsg --help

Common Workflows

List recent chats:

imsg chats --limit 10
imsg chats --limit 10 --json

Inspect one chat before sending or wiring automation:

imsg group --chat-id 42 --json

Read history:

imsg history --chat-id 42 --limit 20
imsg history --chat-id 42 --limit 20 --attachments --json
imsg history --chat-id 42 --start 2026-05-01T00:00:00Z --end 2026-05-06T00:00:00Z --json

Stream new messages:

imsg watch --chat-id 42 --json
imsg watch --chat-id 42 --since-rowid 9000 --attachments --reactions --debounce 250ms --json

Send a message or file:

imsg send --to "+14155551212" --text "hi" --service imessage
imsg send --to "Jane Appleseed" --text "voice note" --file ~/Desktop/voice.m4a
imsg send --chat-id 42 --text "same thread"

Send a standard tapback:

imsg react --chat-id 42 --reaction like

Generate integration help:

imsg completions zsh
imsg completions llm

Commands

  • imsg chats [--limit 20] [--json]
  • imsg group --chat-id <id> [--json]
  • imsg history --chat-id <id> [--limit 50] [--attachments] [--convert-attachments] [--participants <handles>] [--start <iso>] [--end <iso>] [--json]
  • imsg watch [--chat-id <id>] [--since-rowid <id>] [--debounce <duration>] [--attachments] [--convert-attachments] [--reactions] [--participants <handles>] [--start <iso>] [--end <iso>] [--json]
  • imsg send (--to <handle-or-contact-name> | --chat-id <id> | --chat-identifier <id> | --chat-guid <guid>) [--text <text>] [--file <path>] [--service imessage|sms|auto] [--region US] [--json]
  • imsg react --chat-id <id> --reaction love|like|dislike|laugh|emphasis|question
  • imsg read --to <handle> [--chat-id <id> | --chat-identifier <id> | --chat-guid <guid>]
  • imsg typing --to <handle> [--duration 5s] [--stop true] [--service imessage|sms|auto]
  • imsg status [--json]
  • imsg launch [--dylib <path>] [--kill-only] [--json]
  • imsg rpc
  • imsg completions bash|zsh|fish|llm

react intentionally sends only the standard tapbacks that Messages.app exposes reliably through automation. Custom emoji tapbacks can be read from history/watch output, but are not sent by the CLI.

JSON Output

--json emits one JSON object per line, so consumers can stream it directly or collect it with jq -s.

Chat objects include:

  • id, name, identifier, guid, service, last_message_at
  • display_name, contact_name
  • is_group, participants
  • account_id, account_login, last_addressed_handle

Message objects include:

  • id, chat_id, chat_identifier, chat_guid, chat_name
  • participants, is_group
  • guid, reply_to_guid, destination_caller_id
  • sender, sender_name, is_from_me, text, created_at
  • attachments, reactions

When watch --reactions --json sees a tapback event, the message object also includes is_reaction, reaction_type, reaction_emoji, is_reaction_add, and reacted_to_guid.

Routing fields such as destination_caller_id, account_id, account_login, and last_addressed_handle are read-only diagnostics from Messages. AppleScript does not expose a way for imsg send to force a specific outgoing Apple ID phone number or inline reply target.

JSON-RPC

imsg rpc speaks JSON-RPC 2.0 over stdin/stdout, one JSON object per line. It is intended for agents and long-running integrations that want a single process for chats, history, send, and watch.

Read methods:

  • chats.list
  • messages.history
  • watch.subscribe
  • watch.unsubscribe

Mutating method:

  • send

See docs/rpc.md for request and response shapes.

Attachments

--attachments reports metadata only. It does not copy or upload files.

Attachment metadata includes filename, transfer name, UTI, MIME type, byte count, sticker flag, missing flag, and resolved original path.

--convert-attachments can expose cached, model-compatible receive-side variants:

  • CAF audio -> M4A
  • GIF image -> first-frame PNG

Conversion requires ffmpeg on PATH. Original Messages attachments are left unchanged. Converted metadata is reported with converted_path and converted_mime_type.

send --file sends regular files, including audio files, through Messages.app. Before handing the file to Messages, imsg stages it under ~/Library/Messages/Attachments/imsg/ so Messages can read it reliably.

Watch Behavior

imsg watch starts at the newest message by default and streams messages written after it starts. Use --since-rowid <id> to resume from a stored cursor.

The watcher listens for filesystem events on chat.db, chat.db-wal, and chat.db-shm, then backs that up with a lightweight poll. The poll keeps streams alive when macOS drops file events or rotates SQLite sidecar files.

RPC watch defaults to a 500ms debounce to reduce outbound echo races. CLI watch can be tuned with --debounce.

Permissions Troubleshooting

If reads fail with unable to open database file, empty output, or authorization denied:

  1. Open System Settings -> Privacy & Security -> Full Disk Access.
  2. Add the terminal or parent app that launches imsg.
  3. If launched from an editor, Node process, gateway, or shell wrapper, grant Full Disk Access to that parent app too.
  4. Also add the built-in Terminal.app at /System/Applications/Utilities/Terminal.app; macOS can still consult the default terminal grant.
  5. Toggle stale Full Disk Access entries off and on after terminal, Homebrew, Node, or app updates.
  6. Confirm Messages.app is signed in and ~/Library/Messages/chat.db exists.

For sends and tapbacks, allow the terminal or parent app under Privacy & Security -> Automation -> Messages.

imsg opens chat.db read-only. It does not use SQLite immutable=1 by default because immutable reads can miss WAL-backed Messages updates.

Advanced IMCore Features

Default send, chats, history, watch, and read-only rpc workflows do not require IMCore injection.

Advanced features such as read, typing, launch, bridge-backed rich send, message mutation, and chat management are opt-in. They require SIP to be disabled and a helper dylib to be injected into Messages.app:

make build-dylib
imsg launch
imsg status

Important limits:

  • imsg launch refuses to inject when SIP is enabled.
  • imsg status is read-only and does not auto-launch or auto-inject.
  • macOS 26/Tahoe can block injection through library validation.
  • macOS 26/Tahoe can also reject direct IMCore clients through imagent private-entitlement checks.
  • These limits affect advanced IMCore features such as typing indicators, not normal send/history/watch usage.

To revert after testing advanced features, re-enable SIP from Recovery mode with csrutil enable.

Bridge command surface

The bridge implements a manual port of the BlueBubbles private-API surface inspired by their Apache-2.0 helper, into our own dylib (no third-party binary). Commands in this section require imsg launch first, which means SIP-disabled DYLD injection into Messages.app. Most commands take a --chat argument that is the chat guid (e.g. iMessage;-;+15551234567 or iMessage;+;chat0000 for groups). Get a chat guid via imsg chats --json.

Messaging:

# Rich send with effect + reply
imsg send-rich --chat 'iMessage;-;+15551234567' --text "boom" \
  --effect com.apple.MobileSMS.expressivesend.impact \
  --reply-to <messageGuid>

# Text formatting (macOS 15+ Sequoia only): bold/italic/underline/strikethrough
# applied to specific ranges of the message body.
imsg send-rich --chat ... --text 'hello world' \
  --format '[{"start":0,"length":5,"styles":["bold"]},
             {"start":6,"length":5,"styles":["italic","underline"]}]'

# Or load the ranges from a file
imsg send-rich --chat ... --text "$(cat msg.txt)" --format-file ranges.json

# Multipart send (text-only in v1; per-part textFormatting also supported)
imsg send-multipart --chat 'iMessage;+;chat0000' \
  --parts '[{"text":"hi"},
            {"text":"there","textFormatting":[{"start":0,"length":5,"styles":["bold"]}]}]'

# Attachment (file or audio)
imsg send-attachment --chat ... --file ~/Pictures/img.jpg
imsg send-attachment --chat ... --file ~/audio.caf --audio

# Tapback (bridge-backed; `imsg react` remains the AppleScript variant)
imsg tapback --chat ... --message <guid> --kind love
imsg tapback --chat ... --message <guid> --kind love --remove

Mutate (macOS 13+ — selector availability surfaced in imsg status):

imsg edit --chat ... --message <guid> --new-text "actually..."
imsg unsend --chat ... --message <guid>
imsg delete-message --chat ... --message <guid>
imsg notify-anyways --chat ... --message <guid>

Chat management:

imsg chat-create --addresses '+15551111111,+15552222222' --name 'Crew' --text 'gm'
imsg chat-name --chat ... --name 'Renamed'
imsg chat-photo --chat ... --file ~/Downloads/g.jpg     # set
imsg chat-photo --chat ...                              # clear
imsg chat-add-member --chat ... --address +15553333333
imsg chat-remove-member --chat ... --address +15553333333
imsg chat-leave --chat ...
imsg chat-delete --chat ...
imsg chat-mark --chat ... --read     # or --unread

chat-create currently creates iMessage chats only. SMS sending remains available through imsg send --service sms.

Introspection:

imsg account                                            # active iMessage account + aliases
imsg whois --address +15551234567 --type phone
imsg whois --address foo@bar.com --type email
imsg nickname --address +15551234567

Local history search (does not require the bridge):

imsg search --query "pizza" --match contains

Live events (typing indicators surfaced through the dylib):

imsg watch --bb-events                                  # merge dylib events into stdout
imsg watch --bb-events --json                           # one JSON object per event

v2 IPC under the hood

The dylib v1 used a single overwriting .imsg-command.json polled at 100ms, which races when multiple CLI invocations run concurrently. v2 uses a per-request UUID-keyed queue:

~/Library/Containers/com.apple.MobileSMS/Data/
  .imsg-bridge-ready          PID lock — set when injection is live
  .imsg-rpc/in/<uuid>.json    requests dropped here by the CLI (atomic rename)
  .imsg-rpc/out/<uuid>.json   responses written by the dylib (atomic rename)
  .imsg-events.jsonl          inbound async events (typing, alias-removed)

Set IMSG_BRIDGE_LEGACY_IPC=1 to force the legacy single-file path for debugging (existing v1 callers / un-rebuilt dylibs continue to work without this).

Development

make lint
make test
make build

make test applies the repository's SQLite.swift patch before running Swift tests.

The reusable Swift core lives in Sources/IMsgCore; the CLI target lives in Sources/imsg.