rpc.md
August 2, 2026 · View on GitHub
imsg rpc exposes the read and send surfaces over JSON-RPC 2.0 on stdin/stdout. It's designed for agents and gateways that want a single long-lived process for chats, history, send, and watch — without a TCP port, daemon, or system service.
Transport
- One JSON object per line on stdin (request) and stdout (response/notification).
- JSON-RPC 2.0 framing:
jsonrpc,id,method,params. - Notifications omit
id. - Stderr is reserved for human-readable diagnostics.
- Startup failures such as missing Full Disk Access are returned as JSON-RPC errors on the first request instead of human-readable stdout banners.
Lifecycle
- The host process spawns one
imsg rpcchild. - The child stays alive across many requests and one-or-more watch subscriptions.
- No TCP port. No launch agent. No
imsgdaemon to install.
The pattern intentionally mirrors language servers and the way imsg's parent gateway (Clawdis) supervises subprocesses — a single signal-style child that exits cleanly when stdin closes.
Methods
chats.list
Params:
limit(int, default 20)unread_only(bool, defaultfalse) — when true, return only chats withunread_count > 0; unavailable database schemas return an invalid-params error rather than an empty list
Result:
{ "chats": [Chat] }
messages.stats
Params:
chat_id(int, optional)time_zone(IANA identifier, optional; defaults to the local timezone)include_media(bool, defaultfalse)
Result:
{
"total_messages": 123,
"sent_messages": 60,
"received_messages": 63,
"time_zone": "Europe/Vienna",
"chats": [],
"senders": [],
"services": [],
"dates": []
}
When media is requested, media includes distinct attachment totals and bytes grouped by
UTI/MIME and chat. Otherwise the media key is omitted. Invalid, non-positive, or nonexistent
chat_id values return invalid params rather than widening to all chats.
messages.history
Params:
chat_id(int, required) — preferred identifier.limit(int, default 50)participants(array of handle strings, optional)start/end(ISO 8601, optional)attachments(bool, defaultfalse)
Result:
{ "messages": [Message] }
messages.after
Reads a bounded page in stable message ROWID order. This is the resumable
history surface for message catchup; unlike messages.history, it does not
order by timestamp or return the newest rows first.
Params:
since_rowid(int, required) — exclusive, non-negative cursor.chat_id(int, optional) — omit to page across all chats.limit(int, default 100, maximum 500)attachments(bool, defaultfalse)convert_attachments(bool, defaultfalse)include_reactions(bool, defaultfalse) — include standalone reaction events in the ordered scan.
Result:
{
"messages": [Message],
"next_rowid": 500,
"has_more": true
}
Messages are ordered by message.ROWID ASC, including when timestamps are
equal. limit bounds the returned user-visible messages; the scan can consume
additional URL-preview rows while coalescing or suppressing them. next_rowid
is the authoritative physical scan cursor and may therefore advance past the
final returned message. A page can be empty when only suppressed preview rows
remain. Persist next_rowid after every response, then request another page
while has_more is true. Do not infer pagination state from the message count
or final message id. Set include_reactions to true when the cursor must also
cover reaction events; with the default, the cursor tracks user-visible message
catchup only.
ROWID cursors are scoped to the exact Messages database instance that produced
them. They are not portable between machines, accounts, or database files, and
they are not durable across replacement, restoration, or recreation of
chat.db. After any database replacement, discard the saved cursor and start a
new scan from a cursor appropriate for that database instance.
messages.scheduled
Reads future outbound Send Later rows from chat.db. This method is read-only and does not require the IMCore bridge.
Params:
limit(positive int, default 50)
Result:
{ "messages": [ScheduledMessage] }
Older Messages database schemas without scheduling columns return an invalid-params error rather than an ambiguous empty list.
watch.subscribe
Params:
chat_id(int, optional) — omit for all-chat stream.since_rowid(int, optional) — exclusive cursor.participants(array, optional)start/end(ISO 8601, optional)attachments(bool, defaultfalse)include_reactions(bool, defaultfalse)debounce_ms(int, default500)
Result:
{ "subscription": 1 }
Notifications (one per emitted message):
{
"jsonrpc": "2.0",
"method": "message",
"params": {
"subscription": 1,
"message": { ... }
}
}
The RPC default debounce (500ms) is intentionally higher than the CLI default (250ms). RPC's typical caller is an agent that just sent a message and is waiting for the inbound echo to settle (is_from_me correction, attachment metadata, …). 500ms is enough for those follow-ups to land before the message is emitted.
Like the CLI watch, RPC watch backs filesystem events with a low-frequency poll so a missed event or a rotated SQLite sidecar doesn't leave the subscription silent.
If a live all-chat row appears before Messages has joined it to a chat, RPC watch retries it briefly and then drops it fail-closed instead of emitting an empty chat_id=0 direct-message-shaped payload.
watch.unsubscribe
Params:
subscription(int, required)
Result:
{ "ok": true }
send
Params (direct send):
to(string, required)text(string, optional)file(string, optional)service(imessage|sms|auto, optional)region(string, optional)
Params (chat target):
chat_idorchat_identifierorchat_guid— exactly one.chat_idis preferred.text/fileas above.
Result:
{ "ok": true, "id": 1979, "guid": "8DF..." }
id and guid are best-effort. send returns them when the inserted row can be observed in chat.db after Messages accepts the send. Attachment-only sends, delayed database writes, or ambiguous direct sends may return only {"ok": true}.
For chat-target sends, send also performs the Tahoe ghost-row check: if Messages writes an empty unjoined SMS row instead of delivering, the call returns an error rather than {"ok": true}.
message.send_status
Params:
guid(string, required) — outgoing message GUID.
Result:
{
"ok": true,
"guid": "8DF...",
"send_state": "delivered",
"service": "iMessage",
"checked_at": "2026-05-28T20:43:00Z",
"delivered_at": "2026-05-28T20:42:58Z",
"status_fields": {
"is_sent": true,
"is_delivered": true,
"is_finished": true,
"error": 0,
"date_delivered": "2026-05-28T20:42:58Z",
"date_read": null,
"is_delayed": false,
"is_prepared": false,
"is_pending_satellite_send": false,
"was_downgraded": false
}
}
send_state is normalized to pending, sent, delivered, or failed.
Missing rows return pending with status_fields: null.
Bridge Message Actions
These methods require the IMCore bridge and target an existing chat with chat_id, chat_identifier, or chat_guid.
send.richsends text with optionaleffect,subject,reply_to,part_index,dd_scan, andtext_formatting. Alternatively, pass only one chat target plus an HTTP(S)urlto send an Apple URL-preview balloon. URL mode is iMessage-only and rejects text/send modifiers; metadata or image lookup failure falls back to a metadata-only card, never a plain-message send.send.attachmentsendsfileorpath, with optionalaudio/is_audio/as_voice.tapbacksends or removes a reaction. Params:message_idormessage_guid, plusreaction/kind/emoji, optionalremove.message.editeditsmessage_id/message_guidwithtext.message.unsend,message.delete, andmessage.notifyAnywaystargetmessage_id/message_guid.contacts.shouldShareContactreads Apple Messages' advisory Name & Photo offer eligibility. The result includescan_inspect_offer,can_share, and tri-stateshould_offer.contacts.shareContactCardexplicitly requests Apple Messages Name & Photo sharing. Despite the compatibility name, this does not send a vCard. Success reportsrequested: true, not delivery.
The two contacts.* compatibility methods accept chat_id, chat_identifier,
or chat_guid. Sharing discloses the local Messages profile to every chat
participant and must only be invoked after explicit user confirmation.
Result:
{ "ok": true }
send.rich and send.attachment return guid / message_id when the bridge reports the sent message GUID.
handles.check
Requires the IMCore bridge.
Params:
address(string, required) — phone number or email address.alias_type(phone|email, optional) — inferred fromaddresswhen omitted.service(iMessage, optional) — SMS checks are rejected.
Result:
{
"ok": true,
"address": "+14155551212",
"alias_type": "phone",
"destination": "tel:+14155551212",
"id_status": 1,
"available": true,
"service": "iMessage"
}
Native polls
poll.send creates a native Apple Messages Polls extension balloon through the IMCore bridge. The bridge must be injected with imsg launch; the AppleScript transport cannot send native extension payloads. Messages does not render the poll payload title on the balloon, so poll.send also sends a best-effort plain caption message right after the poll. The caption defaults to question; pass comment when the visible caption should differ from the stored poll question, or set suppress_comment to true when the caller already sent its own visible context and needs only the poll balloon. The camelCase alias suppressComment is also accepted.
Request:
{"jsonrpc":"2.0","id":"poll","method":"poll.send","params":{"chat_id":42,"question":"Dinner?","options":["Pizza","Sushi"]}}
With a caption override:
{"jsonrpc":"2.0","id":"poll","method":"poll.send","params":{"chat_id":42,"question":"Dinner?","comment":"Vote by 5pm","options":["Pizza","Sushi"]}}
Without a caption:
{"jsonrpc":"2.0","id":"poll","method":"poll.send","params":{"chat_id":42,"question":"Dinner?","suppress_comment":true,"options":["Pizza","Sushi"]}}
Response:
{"ok":true,"event":"imessage.poll.created","guid":"...","message_id":"...","poll":{"kind":"created","event":"imessage.poll.created","question":"Dinner?","options":[{"id":"...","text":"Pizza"},{"id":"...","text":"Sushi"}]}}
poll.vote casts a native vote after validating the poll and option against local history.
polls.unvote removes a selection with the same poll/option parameters:
{"jsonrpc":"2.0","id":"vote","method":"poll.vote","params":{"chat_id":42,"poll_guid":"POLL-GUID","option_id":"OPTION-UUID"}}
{"jsonrpc":"2.0","id":"unvote","method":"polls.unvote","params":{"chat_id":42,"poll_guid":"POLL-GUID","option_id":"OPTION-UUID"}}
messages.poll.send is accepted as an alias for poll.send. The caption echo is deliberately best-effort: if the poll is created but the follow-up caption send fails, the RPC still returns the poll result to avoid retrying and creating a duplicate poll.
Stickers
send.sticker sends a validated image file as a sticker-attributed IMCore
transfer. The bridge must be injected with imsg launch; AppleScript cannot
preserve sticker attribution. Stickers are iMessage-only. Accepted images are
PNG/APNG, GIF, or JPEG, at most 500 KiB, 618x618 pixels, 100 frames, and
25 million total decoded pixels.
Request:
{"jsonrpc":"2.0","id":"sticker","method":"send.sticker","params":{"chat_id":42,"file":"~/Desktop/sticker.png","attach_to":"MESSAGE_GUID","part_index":0}}
Response:
{"ok":true,"transfer_guid":"..."}
guid and message_id are included when Messages exposes the newly queued
message immediately; treat them as best-effort. transfer_guid is returned on
every successful bridge send.
Use exactly one of chat_id, chat_identifier, or chat_guid. attach_to
accepts a bare message GUID or p:N/GUID; part_index must agree with an
embedded part and is invalid without attach_to. Unknown parameters and
non-object params fail with invalid params rather than falling back.
Objects
Chat
See JSON output → Chat list item. Every field documented there appears in the RPC chats.list response.
Message
See JSON output → Message. When include_reactions: true, message notifications also include the reaction extension fields (is_reaction, reaction_type, reaction_emoji, is_reaction_add, reacted_to_guid).
Native Apple Messages polls are emitted by messages.history and watch.subscribe with the same poll object documented in JSON output → Native poll extension. For inbound native polls whose payload title is empty, imsg backfills poll.question from the earliest clean caption row that replies to the poll.
account_id, account_login, last_addressed_handle, and outgoing destination_caller_id are read-only routing diagnostics; the AppleScript send API does not expose a from selector.
Examples
Request chats.list:
{"jsonrpc":"2.0","id":"1","method":"chats.list","params":{"limit":10}}
Response:
{"jsonrpc":"2.0","id":"1","result":{"chats":[...]}}
Subscribe to a chat:
{"jsonrpc":"2.0","id":"2","method":"watch.subscribe","params":{"chat_id":1}}
Notification on each new message:
{"jsonrpc":"2.0","method":"message","params":{"subscription":2,"message":{...}}}
Send and receive verification:
{"jsonrpc":"2.0","id":"3","method":"send","params":{"to":"+14155551212","text":"hi"}}
{"jsonrpc":"2.0","id":"3","result":{"ok":true,"transport":"applescript","id":1979,"guid":"8DF..."}}
send accepts transport: "auto" | "bridge" | "applescript". auto
uses the IMCore bridge for existing chats when it is running, then falls back
to AppleScript. Use bridge when the caller requires private-API delivery and
should fail instead of falling back.