Add native iMessage poll support#125
Conversation
|
Codex review: needs maintainer review before merge. Reviewed May 27, 2026, 9:26 PM ET / 01:26 UTC. Summary Reproducibility: not applicable. this is a feature PR, not a current-main bug report. The branch includes fixture tests and redacted live macOS proof for CLI/RPC send, history/watch readback, vote decoding, and threaded poll behavior. Review metrics: 3 noteworthy metrics.
Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Risk before merge
Maintainer options:
Next step before merge Security Review detailsBest possible solution: Merge only after maintainer acceptance of the native Polls API/support boundary and private-bridge compatibility risk, preserving the additive JSON shape and raw-safe payload handling already in the branch. Do we have a high-confidence way to reproduce the issue? Not applicable: this is a feature PR, not a current-main bug report. The branch includes fixture tests and redacted live macOS proof for CLI/RPC send, history/watch readback, vote decoding, and threaded poll behavior. Is this the best way to solve the issue? Unclear pending maintainer product review: the implementation is coherent, additive, and well-covered, but native Polls support depends on private Messages payload semantics and creates a new public CLI/RPC contract. AGENTS.md: found and applied where relevant. Codex review notes: model gpt-5.5, reasoning high; reviewed against fbae9cd746ad. Label changesLabel justifications:
Evidence reviewedWhat I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9f5c812dfa
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let payloadData = try dataValue(row, columns.payloadData) | ||
| let messageSummaryInfo = try dataValue(row, columns.messageSummaryInfo) |
There was a problem hiding this comment.
Avoid copying poll blobs for every decoded message row
decodeMessageRow now unconditionally materializes payload_data and message_summary_info into Data for every row, even though MessagePollDecoder.decode immediately returns nil for non-poll rows (balloon_bundle_id not Polls and associated_message_type != 4000). In large histories/watch streams this forces unnecessary BLOB reads/copies for unrelated messages (including large extension payloads), which can noticeably increase memory and latency. Gate these reads behind a cheap poll-candidate check (or make the columns lazy) so non-poll traffic keeps previous performance characteristics.
Useful? React with 👍 / 👎.
|
|
||
| let originalGUID = normalizedAssociatedGUID(associatedMessageGUID) | ||
| let senderHandle = sender.isEmpty ? nil : sender | ||
| let creator = facts.creator ?? senderHandle |
There was a problem hiding this comment.
Do not infer poll creator from the vote sender
For vote rows that do not carry creator* fields, creator is currently backfilled from sender, which is typically the voter, not the poll creator. That emits incorrect metadata (poll.creator) and can mislead downstream routing/analytics that treat creator as poll author. Only populate creator when it is actually present in decoded poll payload facts (or when handling a creation event where that fallback is semantically valid).
Useful? React with 👍 / 👎.
|
ClawSweeper PR egg ✨ Hatched: 💎 rare Frosted Branchling Hatch commandComment Hatchability rules:
Rarity: 💎 rare. What is this egg doing here?
|
|
Runtime proof from a SIP-disabled macOS Messages setup, using the latest PR branch. No new poll was sent for this proof pass; these results come from existing rows created during earlier validation. EnvironmentBridge readiness{
"v2_ready": true,
"advanced_features": true,
"sip": "disabled",
"bridge_version": 2,
"selectors": {
"pollPayloadMessage": true
},
"rpc_methods": [
"poll.send",
"messages.poll.send"
]
}The existing chat containing native Polls rows resolved successfully. Private chat identifiers, handles, message GUIDs, account aliases, and option IDs are redacted. Created poll history proofRecent created-poll rows decode as native poll events: [
{
"is_from_me": true,
"text": "�",
"poll": {
"event": "imessage.poll.created",
"kind": "created",
"question": "OpenClaw RPC poll final test",
"options": [{"id":"<redacted>","text":"A"},{"id":"<redacted>","text":"B"}],
"metadata": {
"payload_bytes": 7411,
"url_scheme": "data",
"bundle_id": "com.apple.messages.MSMessageExtensionBalloonPlugin:0000000000:com.apple.messages.Polls",
"summary_bytes": 61,
"associated_message_type": 0,
"query_keys": ["c","src"]
}
}
},
{
"is_from_me": true,
"text": "�",
"poll": {
"event": "imessage.poll.created",
"kind": "created",
"question": "OpenClaw complex poll retest: pick the best next validation",
"options_count": 5,
"metadata": {
"payload_bytes": 8423,
"url_scheme": "data",
"summary_bytes": 61,
"query_keys": ["c","src"]
}
}
},
{
"is_from_me": true,
"text": "�",
"poll": {
"event": "imessage.poll.created",
"kind": "created",
"question": "OpenClaw imsg poll envelope retest",
"options": [{"id":"<redacted>","text":"A"},{"id":"<redacted>","text":"B"}],
"metadata": {
"payload_bytes": 7419,
"url_scheme": "data",
"summary_bytes": 61,
"query_keys": ["c","src"]
}
}
}
]Delivery state[
{"is_sent":1,"is_delivered":1,"is_finished":1,"error":0,"service":"iMessage","payload_bytes":7411},
{"is_sent":1,"is_delivered":1,"is_finished":1,"error":0,"service":"iMessage","payload_bytes":8423},
{"is_sent":1,"is_delivered":1,"is_finished":1,"error":0,"service":"iMessage","payload_bytes":7419},
{"is_sent":1,"is_delivered":0,"is_finished":1,"error":0,"service":"iMessage","payload_bytes":1073}
]The 1073-byte row is the known pre-fix failure. The three newer rows are the fixed native poll payloads and are delivered. Vote history proof[
{
"is_from_me": false,
"text": " ",
"poll": {
"event": "imessage.poll.voted",
"kind": "vote",
"original_guid_present": true,
"poll_guid_present": true,
"vote": {
"option_id": "<redacted>",
"participant_present": true,
"event_type": "selected"
}
}
}
]Multiple recent vote rows show the same decoded shape: Watch noteI also started the sanitized watch command and asked for a vote. No live poll event arrived during that capture window, so I stopped it. Existing history proof confirms vote decoding from durable Messages rows. @clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
Fresh proof for This pass focused on poll replies, since non-threaded native poll send/read/vote was already proven earlier. Local verification
Runtime environmentBridge was rebuilt and force-reloaded before testing: {
"v2_ready": true,
"advanced_features": true,
"sip": "disabled",
"bridge_version": 2,
"poll_payload_message": true,
"poll_send_present": true,
"messages_poll_send_present": true,
"spike_marker_present": false
}Threaded poll validationI created a normal parent message with Sanitized history proof: {
"poll_found": true,
"poll_event": "imessage.poll.created",
"options_count": 2,
"reply_to_guid_present": true,
"reply_to_text_present": true,
"reply_to_sender_present": true,
"thread_originator_guid_present": true,
"thread_originator_part_present": true
}Sanitized DB proof for the same row: {
"is_sent": 1,
"is_delivered": 1,
"is_finished": 1,
"error": 0,
"payload_bytes": 7475,
"reply_to_guid_matches_parent": true,
"thread_originator_guid_matches_parent": true,
"thread_originator_part_present": true,
"associated_message_guid_present": false,
"associated_message_type": 0
}The reply send path now uses the same IMMessageItem-first thread-originator approach as rich message threading. I also removed the temporary plugin-payload spike from the final patch. The final bridge status confirms the spike marker is gone. @clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
Follow-up for I sat with this for a few days after the threaded poll work and realized there was still a gap in the first version. Sending native polls was working, and threaded sends were working, but the read/watch side was asking downstream consumers to do too much. Votes exposed stable option IDs, but not the option text. Watch needed explicit proof that poll votes come through without opting into tapback reaction events. And the native “add choice” flow is not just a vote: Apple emits a Polls update row before the later vote row. I wanted to close that before asking maintainers to take this as a real feature surface. The latest commit makes the behavior more complete and easier to consume without adding a local hack. What changed in this follow-up:
That last point matters for OpenClaw-style consumers. A custom-added option now looks like a Polls update event that can be tied back to the original poll, instead of looking like an unrelated fresh poll. Local verification
Runtime proof on OpenClaw machineThe installed wrapper is pointed at the validated build from this branch. Status through that wrapper: {
"v2_ready": true,
"advanced_features": true,
"sip": "disabled",
"bridge_version": 2,
"selectors": { "pollPayloadMessage": true },
"rpc_methods_include": ["poll.send", "messages.poll.send"]
}I tested both CLI and RPC send paths against the same existing native Polls chat, with private chat IDs, handles, GUIDs, and option IDs omitted. CLI path: RPC path: Sanitized history proof for the RPC-created threaded poll: {
"root_found": true,
"rpc_poll": {
"is_from_me": true,
"reply_to_guid_present": true,
"thread_originator_guid_present": true,
"thread_originator_part_present": true,
"reply_to_text_matches_root": true,
"poll": {
"event": "imessage.poll.created",
"kind": "created",
"question_present": true,
"options_count": 2,
"option_ids_present": true,
"option_texts": ["RPC threaded works", "Needs changes"]
}
}
}Watch and vote proofI then left Sanitized watch/history proof: {
"latest_vote": {
"original_guid_present": true,
"vote_option_id_present": true,
"vote_option_text": "Threaded works",
"votes_count": 2,
"vote_texts": [
"Threaded works",
"Yep. It works and is threaded visually"
]
}
}The recipient also tested selecting, unselecting, selecting again, adding a custom choice, and selecting that custom choice. That produced Apple’s separate Polls update row, which now decodes with a clean correlation back to the source poll: {
"custom_add_update": {
"reply_to_guid_present": true,
"original_guid_present": true,
"poll_guid_present": true,
"associated_type": 2,
"option_texts": [
"Threaded works",
"Needs changes",
"Yep. It works and is threaded visually"
]
}
}The original imsg-created threaded poll row has For OpenClaw, the practical routing shape is now:
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
🦞👀 Command router queued. I will update this comment with the next step. Re-review progress:
|
|
Follow-up for ClawSweeper was right about the threaded payload gap. The threaded poll path was visually working, but the bridge still blanked the question before building the durable native Polls payload. That meant the message could render as a threaded poll while leaving history/watch readback too dependent on other metadata. This commit removes that special case. Threaded poll sends now pass the requested question into One broader note on the PR: this ended up improving more than just poll send. The branch adds a tested pattern for native Messages extension balloons, validates the IMMessageItem-first threading path for bridge sends, exposes reply/thread metadata in normal JSON output, keeps poll BLOB reads gated to poll candidate rows, and gives watch/history consumers stable events instead of forcing them to re-query private DB state. The JSON shape stays additive, unknown private payloads stay raw-safe, and normal message behavior is preserved. Local verification: Live validation on a SIP-disabled macOS Messages machine: {
"found": true,
"is_from_me": true,
"reply_to_guid_matches_root": true,
"reply_to_text_present": true,
"thread_originator_guid_matches_root": true,
"thread_originator_part_present": true,
"poll": {
"event": "imessage.poll.created",
"kind": "created",
"question_matches": true,
"options_count": 2,
"option_texts": [
"Question preserved",
"Thread remains visual"
]
}
}DB delivery/thread proof for the same row: {
"poll_row_found": 1,
"is_sent": 1,
"is_delivered": 1,
"is_finished": 1,
"error": 0,
"reply_to_guid_matches_root": 1,
"thread_originator_guid_matches_root": 1,
"thread_originator_part_present": 1,
"payload_bytes": 7876,
"associated_message_type": 0
}Recipient-side visual check also confirmed the poll appeared as a threaded reply. This should address the remaining P2: threaded polls now keep the question in the native payload, preserve history/watch readback, and still render visually as threaded Messages replies. @clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
c57d0d1 to
9db467f
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9db467fe53
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| SELECT \(selection.selectList) | ||
| FROM message m | ||
| LEFT JOIN handle h ON m.handle_id = h.ROWID | ||
| WHERE m.guid = ? |
There was a problem hiding this comment.
Include poll update rows when resolving vote option text
When an option is added by a poll update row (associated_message_type == 2 with associated_message_guid pointing back to the original poll) and a later vote still references the original poll GUID, this lookup only reads the original creation row. Any option IDs introduced by the update row therefore remain without option_text even though the update payload is present in chat.db. The decoder already preserves update rows via originalGUID, so the resolver should also consider Polls rows associated with this GUID, preferably the latest update before marking the GUID as missing.
Useful? React with 👍 / 👎.


Closes #124.
Why
This adds native Apple Messages poll support to imsg so approval workflows can happen directly inside iMessage.
Text commands work, but they are easy to misread on mobile and awkward for multi-choice decisions. Native polls give agents and automation clients a clearer pattern: present the task, offer explicit choices, let the user respond with one tap, then route that decision back through history/watch/RPC.
The main use case is safer autonomy. A client can ask before creating inferred tasks, sending drafts, changing priorities, or taking ambiguous actions, while keeping the approval trail inside the normal Messages thread.
Summary
This PR adds native Apple Messages Polls support across read, watch, send, CLI, and RPC.
history,watch, and RPC message outputimessage.poll.createdimessage.poll.votedimessage.poll.unknownimsg poll sendpoll.sendandmessages.poll.sendRPC methods--reply-to <message-guid>and matching RPC reply fieldsreply_to_guid,reply_to_text,reply_to_sender,thread_originator_guid, andthread_originator_partvote.option_textwhen the referenced poll row is availableassociated_message_type = 2The JSON shape is additive. Existing message fields are preserved, and non-poll messages keep their current behavior.
Broader project benefit
This ended up improving more than poll send.
Testing
Local:
make lintstill cannot fully complete on my machine becauseswiftlintis not installed locally. The Swift format lint step passes.Manual validation on a SIP-disabled macOS 26.5 Messages setup covered:
v2_ready=true,advanced_features=true, andselectors.pollPayloadMessage=truepoll.senddelivered and rendered as a real Messages pollimessage.poll.votedreply_to_guid,thread_originator_guid,thread_originator_part, and the poll questionwatchemitted poll vote rows without requiring reaction watchingThe first send implementation produced a local poll row that did not deliver. The final implementation fixes that by building the native Messages layout envelope Apple-created polls use, including
MSMessageTemplateLayout,liveLayoutInfo,userInfo,ldtext, and related Polls markers.