Skip to content

Add native iMessage poll support#125

Merged
steipete merged 9 commits into
openclaw:mainfrom
veteranbv:native-imessage-polls
May 28, 2026
Merged

Add native iMessage poll support#125
steipete merged 9 commits into
openclaw:mainfrom
veteranbv:native-imessage-polls

Conversation

@veteranbv
Copy link
Copy Markdown
Contributor

@veteranbv veteranbv commented May 24, 2026

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.

  • decode native Polls extension balloons from history, watch, and RPC message output
  • decode poll vote rows and tie them back to the original poll
  • expose stable poll events:
    • imessage.poll.created
    • imessage.poll.voted
    • imessage.poll.unknown
  • add imsg poll send
  • add poll.send and messages.poll.send RPC methods
  • support threaded native poll replies with --reply-to <message-guid> and matching RPC reply fields
  • preserve threaded poll questions in the durable native payload
  • expose reply_to_guid, reply_to_text, reply_to_sender, thread_originator_guid, and thread_originator_part
  • include vote.option_text when the referenced poll row is available
  • handle native Polls option-add/update rows with associated_message_type = 2
  • keep unknown private payload variants raw-safe
  • document the JSON, history, watch, CLI, and RPC surfaces

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

  • Adds a tested pattern for native Messages extension balloons.
  • Validates the IMMessageItem-first thread-originator path for bridge sends.
  • Makes reply/thread metadata easier for clients to consume.
  • Keeps poll BLOB reads gated to poll candidate rows.
  • Lets watch/history consumers react to structured events instead of re-querying private DB state.
  • Preserves normal message behavior while adding the new native poll surface.

Testing

Local:

swift test
264 tests passed

make test
264 tests passed

swift format lint --recursive Sources Tests TestsLinux
# passed

git diff --check
# passed

make build-dylib
Built .build/release/imsg-bridge-helper.dylib
# existing unarchiveObjectWithData: deprecation warning only

make lint still cannot fully complete on my machine because swiftlint is not installed locally. The Swift format lint step passes.

Manual validation on a SIP-disabled macOS 26.5 Messages setup covered:

  • bridge status showed v2_ready=true, advanced_features=true, and selectors.pollPayloadMessage=true
  • CLI native poll send delivered and rendered as a real Messages poll
  • RPC poll.send delivered and rendered as a real Messages poll
  • history decoded created polls with question, options, and option IDs
  • recipient votes decoded as imessage.poll.voted
  • vote rows included original poll linkage, participant metadata, option ID, and option text
  • multi-option polls delivered and decoded correctly
  • threaded poll replies rendered visually as Messages replies
  • threaded poll rows preserved reply_to_guid, thread_originator_guid, thread_originator_part, and the poll question
  • watch emitted poll vote rows without requiring reaction watching
  • native “Add Choice” updates decoded and correlated back to the source poll

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

image

@clawsweeper
Copy link
Copy Markdown

clawsweeper Bot commented May 24, 2026

Codex review: needs maintainer review before merge. Reviewed May 27, 2026, 9:26 PM ET / 01:26 UTC.

Summary
The branch adds native Apple Messages poll decoding and sending across history/watch JSON, CLI imsg poll send, RPC poll.send aliases, bridge helper payload construction/threading, docs, and regression fixtures.

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.

  • Changed surface: 42 files, +3747/-179. This is a broad bridge, read/watch, CLI/RPC, docs, build, and test feature rather than a narrow bug fix.
  • Public API additions: 1 CLI command and 2 RPC method names added. New user-facing command and RPC names need maintainer acceptance as part of the compatibility contract.
  • Helper build surface: Makefile and 2 release scripts link AppKit. The native poll preview path changes release/helper dylib build inputs outside Swift tests.

Merge readiness
Overall: 🐚 platinum hermit
Proof: 🦞 diamond lobster
Patch quality: 🐚 platinum hermit
Result: ready for maintainer review.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Rank-up moves:

  • Maintainer should explicitly accept the native Polls API/support boundary and private-bridge compatibility risk before merge.

Risk before merge

  • Native poll sending depends on private Apple Messages selectors and payload shapes; green unit tests cannot prove compatibility across supported macOS versions or future Messages payload drift.
  • The PR adds new CLI/RPC/API JSON fields plus a native bridge send action, so maintainers should explicitly accept the public contract and upgrade/support boundary before merge.
  • Message delivery risk is limited by the supplied live proof, but the change still touches the injected send path where unsupported selectors or payload variants can produce queued or local-only rows.

Maintainer options:

  1. Accept the native Polls bridge boundary (recommended)
    A maintainer can merge after explicitly accepting that native poll support is a private Messages bridge feature with documented macOS/SIP limitations and additive JSON/RPC API surface.
  2. Request more OS matrix proof
    Maintainers can ask for additional macOS version proof if they want confidence beyond the supplied macOS 26.5 SIP-disabled validation before committing to the feature.
  3. Pause if private Polls support is out of scope
    If native Polls is too much private API surface for core imsg, pause or close this PR and keep the linked feature request as a product decision instead.

Next step before merge
The remaining action is maintainer product and compatibility review for a broad private Messages poll API surface, not an automated repair.

Security
Cleared: No concrete security or supply-chain regression was found; the diff adds no third-party dependency or secret handling path, and poll payload output stays raw-safe.

Review details

Best 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 changes

Label justifications:

  • P3: This is a speculative but useful new feature with strong proof, not an urgent regression or core availability failure.
  • merge-risk: 🚨 compatibility: The PR adds a new public poll API and relies on private Messages selectors/payload formats that may vary by macOS version.
  • merge-risk: 🚨 message-delivery: The new native send path can affect whether poll messages are delivered or become local-only queued rows if the private payload shape drifts.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • feature: ✨ showcase: ClawSweeper spotlight: unusually compelling feature idea for maintainer attention. Native one-tap iMessage polls create a compelling approval workflow for agent decisions without leaving the Messages thread.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (logs): The PR discussion contains redacted live macOS runtime output and screenshots showing after-fix native poll delivery, threaded send, history/watch readback, and vote/custom-choice decoding.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR discussion contains redacted live macOS runtime output and screenshots showing after-fix native poll delivery, threaded send, history/watch readback, and vote/custom-choice decoding.
Evidence reviewed

What I checked:

  • Repository policy read: AGENTS.md was read fully; its Swift style, testing, focused-changes, and macOS permission guidance were applied to the review. (AGENTS.md:1, fbae9cd746ad)
  • Current main lacks the requested poll surface: Searching current main found no MessagePoll, poll.send, messages.poll.send, send-poll, or com.apple.messages.Polls implementation/docs/tests, so the PR is not obsolete on main. (fbae9cd746ad)
  • Broad PR surface: The PR changes 42 files with 3747 additions and 179 deletions across core decoding, helper dylib, CLI/RPC, docs, build scripts, and tests. (c57d0d1b6db4)
  • Poll decoder and JSON model added: The branch adds MessagePollEvent, route-friendly poll events, raw-safe metadata, and decode gating for native Polls rows and vote associations. (Sources/IMsgCore/MessagePolls.swift:87, c57d0d1b6db4)
  • Poll send APIs added: The branch adds the poll CLI command and RPC poll.send/messages.poll.send handlers that route through the new bridge action. (Sources/imsg/Commands/BridgeMessagingCommands.swift:347, c57d0d1b6db4)
  • Native bridge payload construction added: The helper dylib constructs native Polls payload envelopes, advertises selector readiness, and builds threaded poll IMMessage instances using private Messages selectors. (Sources/IMsgHelper/IMsgInjected.m:1562, c57d0d1b6db4)

Likely related people:

  • Omar Shahine: Introduced the BlueBubbles-style private IMCore bridge and later fixed macOS 26 bridge regressions in the same helper, CLI, and RPC areas extended by this PR. (role: bridge feature owner; confidence: high; commits: c56c24d488ef, 2d7b506d1736; files: Sources/IMsgHelper/IMsgInjected.m, Sources/imsg/Commands/BridgeMessagingCommands.swift, Sources/imsg/RPCServer.swift)
  • Peter Steinberger: Refactored the message store query layers that this PR extends and prepared the current v0.9.0 release snapshot containing the central files. (role: message store and release-area contributor; confidence: medium; commits: 6a05484ae6b1, b85e701f2b0b; files: Sources/IMsgCore/MessageStore+Messages.swift, Sources/IMsgCore/MessageStore+Queries.swift, Sources/IMsgCore/MessageStoreSchema.swift)
What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

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
  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +368 to +369
let payloadData = try dataValue(row, columns.payloadData)
let messageSummaryInfo = try dataValue(row, columns.messageSummaryInfo)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread Sources/IMsgCore/MessagePolls.swift Outdated

let originalGUID = normalizedAssociatedGUID(associatedMessageGUID)
let senderHandle = sender.isEmpty ? nil : sender
let creator = facts.creator ?? senderHandle
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 clawsweeper Bot added rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. feature: ✨ showcase ClawSweeper spotlight: unusually compelling feature idea for maintainer attention. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels May 24, 2026
@clawsweeper
Copy link
Copy Markdown

clawsweeper Bot commented May 24, 2026

ClawSweeper PR egg

✨ Hatched: 💎 rare Frosted Branchling

Hatch command

Comment @clawsweeper hatch when this PR is hatchable.

Hatchability rules:

  • Merged PRs are hatchable.
  • Open PRs are hatchable when they are status: 👀 ready for maintainer look, status: 🚀 automerge armed, or labeled clawsweeper:automerge.
  • Closed unmerged PRs are hatchable only when one of those hatchable labels is still present in the durable record.

Rarity: 💎 rare.
Trait: sleeps inside passing CI.
Image traits: location flaky test forest; accessory review stamp; palette sunrise gold and clean white; mood watchful; pose pointing at a small proof artifact; shell polished stone shell; lighting tiny status-light glow; background tiny shells and proof notes.
Share on X: post this hatch
Copy: My PR egg hatched a 💎 rare Frosted Branchling in ClawSweeper.

What is this egg doing here?
  • Eggs appear after the PR passes real-behavior proof. It is here for vibes, not verdicts: it does not change labels, ratings, merge decisions, or automation.
  • The shell reacts to review momentum: open follow-up work warms it up, re-review makes it wobble, and a clean final review lets it hatch.
  • Hatchability usually comes from sufficient real-behavior proof, no blocking P0/P1/P2 findings, no security attention needed, and clean correctness. A merged PR is already final, so merge makes the egg hatchable independently.
  • The hatch is seeded from this repository and PR number, so the same PR keeps the same creature; the reviewed head SHA can only change safe visual details.
  • Rarity is just collectible sparkle: 🥚 common, 🌱 uncommon, 💎 rare, ✨ glimmer, and 🌈 legendary.

@veteranbv
Copy link
Copy Markdown
Contributor Author

Screenshot 2026-05-24 at 00 00 16 This is a screenshot of my conversation with my OpenClaw agent "Wit" asking him to create a poll before the patch and him creating polls natively after the patch.

Copy link
Copy Markdown
Contributor Author

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.

Environment

git log -1 --oneline
c998305 fix: address poll review feedback

sw_vers
ProductName:    macOS
ProductVersion: 26.5
BuildVersion:   25F71

csrutil status
System Integrity Protection status: disabled.

Bridge readiness

make build-dylib
Built .build/release/imsg-bridge-helper.dylib
{
  "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 proof

Recent 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: imessage.poll.voted, original poll reference present, poll GUID present, option ID present, and participant metadata present.

Watch note

I 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

@clawsweeper
Copy link
Copy Markdown

clawsweeper Bot commented May 24, 2026

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

Re-review progress:

@clawsweeper clawsweeper Bot added proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. P3 Low-risk cleanup, docs, polish, ergonomics, or speculative feature. merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. merge-risk: 🚨 message-delivery 🚨 Merging this PR could drop, duplicate, misroute, suppress, or wrongly target messages. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels May 24, 2026
Copy link
Copy Markdown
Contributor Author

Fresh proof for a862fd8 feat: support threaded native polls.

This pass focused on poll replies, since non-threaded native poll send/read/vote was already proven earlier.

Local verification

make build-dylib
Built .build/release/imsg-bridge-helper.dylib
# existing unarchiveObjectWithData: deprecation warning only
make test
257 tests passed
git diff --check
# passed

make lint still cannot complete on my machine because swiftlint is not installed. swift format lint --recursive Sources Tests TestsLinux ran before that and produced no findings.

Runtime environment

Commit tested: a862fd8
macOS: 26.5 build 25F71
SIP: disabled

Bridge 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 validation

I created a normal parent message with send-rich --no-dd-scan so the parent GUID resolved to the actual visible row, then sent a native poll reply to that message:

imsg poll send --chat <redacted> --reply-to <redacted-parent-guid> \
  --question "OpenClaw item reply poll 20260527120031" \
  --option "Approve" \
  --option "Needs changes"

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

@clawsweeper
Copy link
Copy Markdown

clawsweeper Bot commented May 27, 2026

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

Re-review progress:

@clawsweeper clawsweeper Bot added rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. and removed rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. labels May 27, 2026
Copy link
Copy Markdown
Contributor Author

veteranbv commented May 28, 2026

Follow-up for a1c9c99 fix: complete native poll readback.

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:

  • Poll vote rows now include vote.option_text when the referenced poll row is available.
  • poll.votes[] also gets option_text, which matters when a user has multiple selected options.
  • watch.subscribe / messagesAfter now have regression coverage showing poll vote rows emit without include_reactions.
  • Poll option-add/update rows keep their own poll_guid and also expose original_guid when Apple sends them with associated_message_type = 2.
  • The custom-add path is covered with fixtures: update row decodes, later vote resolves the custom option text, malformed/unknown handling remains safe.

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

commit tested: a1c9c99
swift test
264 tests passed

make test
264 tests passed

swift format lint --recursive Sources Tests TestsLinux
# passed

git diff --check
# passed

make build-dylib
Built .build/release/imsg-bridge-helper.dylib
# existing unarchiveObjectWithData: deprecation warning only

make lint still cannot fully complete on my local machine because swiftlint is not installed. The Swift format lint step passes.

Runtime proof on OpenClaw machine

macOS: 26.5 build 25F71
SIP: disabled
installed imsg path: /opt/homebrew/bin/imsg

The 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:

imsg send-rich --chat <redacted> --text <root text>
imsg poll send --chat-id <redacted> --reply-to <root-guid> \
  --question <threaded poll question> \
  --option "Threaded works" \
  --option "Needs changes"

RPC path:

send.rich -> root text
poll.send -> threaded native poll with reply_to=<root-guid>

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 proof

I then left imsg watch running without --reactions and voted from the recipient side. The watcher emitted the poll vote row.

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 reply_to_guid, thread_originator_guid, and thread_originator_part. The later Apple-generated custom-add update row does not carry the thread-originator fields, but it now carries original_guid, so consumers can still route it back to the poll it updates.

For OpenClaw, the practical routing shape is now:

  • imessage.poll.created: original poll creation, including threaded poll metadata when imsg sends it.
  • imessage.poll.created with metadata.associated_message_type == 2 and original_guid: native Polls update/add-choice row.
  • imessage.poll.voted: vote row, with vote.option_id, vote.option_text, vote.participant, poll.original_guid, and poll.poll_guid.
  • imessage.poll.unknown: still emitted for unknown private payload variants without exposing raw payload data.

@clawsweeper re-review

@clawsweeper
Copy link
Copy Markdown

clawsweeper Bot commented May 28, 2026

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

@clawsweeper clawsweeper Bot added rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels May 28, 2026
@clawsweeper
Copy link
Copy Markdown

clawsweeper Bot commented May 28, 2026

🦞👀
ClawSweeper picked this up.

Command router queued. I will update this comment with the next step.

Re-review progress:

Copy link
Copy Markdown
Contributor Author

Follow-up for c57d0d1 fix: preserve threaded poll questions.

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 buildPollCreationPayloadData(...), the same as standalone polls. I also changed the bridge registration test so it now guards against reintroducing the blank threaded-question path.

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:

swift test
264 tests passed

make test
264 tests passed

swift format lint --recursive Sources Tests TestsLinux
# passed

git diff --check
# passed

make build-dylib
Built .build/release/imsg-bridge-helper.dylib
# existing unarchiveObjectWithData: deprecation warning only

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

@clawsweeper
Copy link
Copy Markdown

clawsweeper Bot commented May 28, 2026

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

Re-review progress:

@clawsweeper clawsweeper Bot added rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. labels May 28, 2026
@veteranbv
Copy link
Copy Markdown
Contributor Author

image

Supporting threaded polls has been a huge value add for my agent so that I can just tap on needed decisions vs having to type or voice note. Captured a notional example here where my agent messaged me after reviewing email and notes and suggesting tasks for tracking.

Hope this helps others.

@steipete steipete force-pushed the native-imessage-polls branch from c57d0d1 to 9db467f Compare May 28, 2026 21:08
@steipete steipete merged commit afa0763 into openclaw:main May 28, 2026
2 checks passed
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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 = ?
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature: ✨ showcase ClawSweeper spotlight: unusually compelling feature idea for maintainer attention. merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. merge-risk: 🚨 message-delivery 🚨 Merging this PR could drop, duplicate, misroute, suppress, or wrongly target messages. P3 Low-risk cleanup, docs, polish, ergonomics, or speculative feature. proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add native iMessage poll support

2 participants