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 rpc child.
  • The child stays alive across many requests and one-or-more watch subscriptions.
  • No TCP port. No launch agent. No imsg daemon 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, default false) — when true, return only chats with unread_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, default false)

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, default false)

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, default false)
  • convert_attachments (bool, default false)
  • include_reactions (bool, default false) — 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, default false)
  • include_reactions (bool, default false)
  • debounce_ms (int, default 500)

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_id or chat_identifier or chat_guid — exactly one. chat_id is preferred.
  • text / file as 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.rich sends text with optional effect, subject, reply_to, part_index, dd_scan, and text_formatting. Alternatively, pass only one chat target plus an HTTP(S) url to 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.attachment sends file or path, with optional audio / is_audio / as_voice.
  • tapback sends or removes a reaction. Params: message_id or message_guid, plus reaction / kind / emoji, optional remove.
  • message.edit edits message_id / message_guid with text.
  • message.unsend, message.delete, and message.notifyAnyways target message_id / message_guid.
  • contacts.shouldShareContact reads Apple Messages' advisory Name & Photo offer eligibility. The result includes can_inspect_offer, can_share, and tri-state should_offer.
  • contacts.shareContactCard explicitly requests Apple Messages Name & Photo sharing. Despite the compatibility name, this does not send a vCard. Success reports requested: 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 from address when 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.