Status: native external CLI integration. Gateway spawns imsg rpc and communicates over JSON-RPC on stdio (no separate daemon/port). Advanced actions require imsg launch and a successful private API probe.
Messages must be signed in on the Mac running imsg.
Full Disk Access is required for the process context running OpenClaw/imsg (Messages DB access).
Automation permission is required to send messages through Messages.app.
For advanced actions (react / edit / unsend / threaded reply / effects / polls / group ops), System Integrity Protection must be disabled — see Enabling the imsg private API below. Basic text and media send/receive work without it.
SSH wrapper sends fail with AppleEvents -1743
A remote-SSH setup can read chats, pass channels status --probe, and process inbound messages while outbound sends still fail with an AppleEvents authorization error:
Not authorized to send Apple events to Messages. (-1743)
Check the signed-in Mac user’s TCC database or System Settings > Privacy & Security > Automation. If the Automation entry is recorded for /usr/libexec/sshd-keygen-wrapper instead of the imsg or local shell process, macOS may not expose a usable Messages toggle for that SSH server-side client:
In that state, repeating tccutil reset AppleEvents or rerunning imsg send through the same SSH wrapper may keep failing because the process context that needs Messages Automation is the SSH wrapper, not an app the UI can grant.
Use one of the supported imsg process contexts instead:
Run the Gateway, or at least the imsg bridge, in the logged-in Messages user’s local session.
Start the Gateway with a LaunchAgent for that user after granting Full Disk Access and Automation from the same session.
If you keep the two-user SSH topology, verify that a real outbound imsg send succeeds through the exact wrapper before enabling the channel. If it cannot be granted Automation, reconfigure to a single-user imsg setup instead of relying on the SSH wrapper for sends.
Basic mode (default, no SIP changes needed): outbound text and media via send, inbound watch/history, chat list. This is what you get out of the box from a fresh brew install steipete/tap/imsg plus the standard macOS permissions above.
Private API mode: imsg injects a helper dylib into Messages.app to call internal IMCore functions. This is what unlocks react, edit, unsend, reply (threaded), sendWithEffect, poll and poll-vote (native Messages polls), renameGroup, setGroupIcon, addParticipant, removeParticipant, leaveGroup, plus typing indicators and read receipts.
To reach the advanced action surface that this channel page documents, you need Private API mode. The imsg README is explicit about the requirement:
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. imsg launch refuses to inject when SIP is enabled.
The helper-injection technique uses imsg’s own dylib to reach Messages private APIs. There is no third-party server or BlueBubbles runtime in the OpenClaw iMessage path.
Install (or upgrade) imsg on the Mac that runs Messages.app:
Terminal window
brewinstallsteipete/tap/imsg
imsg--version
imsgstatus--json
The imsg status --json output reports bridge_version, rpc_methods, and per-method selectors so you can see what the current build supports before you start.
Disable System Integrity Protection, and (on modern macOS) Library Validation. Injecting a non-Apple helper dylib into the Apple-signed Messages.app needs SIP off and library validation relaxed. The Recovery-mode SIP step is macOS-version-specific:
macOS 10.13-10.15 (Sierra-Catalina): disable Library Validation via Terminal, reboot to Recovery Mode, run csrutil disable, restart.
macOS 11+ (Big Sur and later), Intel: Recovery Mode (or Internet Recovery), csrutil disable, restart.
macOS 11+, Apple Silicon: power-button startup sequence to enter Recovery; on recent macOS versions hold the Left Shift key when you click Continue, then csrutil disable. Virtual-machine setups follow a separate flow, so take a VM snapshot first.
On macOS 11 and later, csrutil disable alone is usually not enough. Apple still enforces library validation against Messages.app as a platform binary, so an adhoc-signed helper is rejected (Library Validation failed: ... platform binary, but mapped file is not) even with SIP off. After disabling SIP, also disable library validation and reboot:
macOS 26 (Tahoe), verified on 26.5.1: SIP off plus the DisableLibraryValidation command above is sufficient to inject the helper across 26.0 through 26.5.x. No boot-args are required. The plist is the decisive factor and the most common missing step when injection fails on Tahoe:
With the plist:imsg launch injects and imsg status reports advanced_features: true.
Without the plist (even with SIP off):imsg launch fails with Failed to launch: Timeout waiting for Messages.app to initialize. AMFI rejects the adhoc helper at load, so the bridge never becomes ready and the launch times out. That timeout is the symptom most people hit on Tahoe, and the fix is the plist above, not anything more drastic.
This was confirmed with a controlled before/after on macOS 26.5.1 (Apple Silicon): with the plist, the dylib maps into Messages.app and the bridge comes up; remove the plist and reboot, and imsg launch produces the timeout failure above with the dylib not mapped.
If imsg launch injection or specific selectors start returning false after a macOS upgrade, this gate is the usual cause. Check your SIP and library-validation state before assuming the SIP step itself failed. If those settings are correct and the bridge still cannot inject, collect imsg status --json plus the imsg launch output and report it to the imsg project instead of weakening additional system-wide security controls.
Follow Apple’s Recovery-mode flow for your Mac to disable SIP before running imsg launch.
Inject the helper. With SIP disabled and Messages.app signed in:
Terminal window
imsglaunch
imsg launch refuses to inject when SIP is still enabled, so this also doubles as a confirmation that step 2 took.
Verify the bridge from OpenClaw:
Terminal window
openclawchannelsstatus--probe
The iMessage entry should report works, and imsg status --json | jq '{rpc_methods, selectors}' should show the capabilities exposed by your macOS build. Poll creation requires selectors.pollPayloadMessage; voting requires both selectors.pollVoteMessage and the poll.vote RPC method. The OpenClaw plugin advertises only actions supported by the cached probe, while an empty cache stays optimistic and probes on first dispatch.
If openclaw channels status --probe reports the channel as works but specific actions throw “iMessage `
requires the imsg private API bridge" at dispatch time, runimsg launchagain — the helper can fall out (Messages.app restart, OS update, etc.) and the cachedavailable: true` status will keep advertising actions until the next probe refreshes.
If SIP-disabled isn’t acceptable for your threat model:
imsg falls back to basic mode — text + media + receive only.
The OpenClaw plugin still advertises text/media send and inbound monitoring; it just hides react, edit, unsend, reply, sendWithEffect, and group ops from the action surface (per the per-method capability gate).
You can run a separate non-Apple-Silicon Mac (or a dedicated bot Mac) with SIP off for the iMessage workload, while keeping SIP enabled on your primary devices. See Dedicated bot macOS user (separate iMessage identity) below.
channels.imessage.dmPolicy controls direct messages:
pairing (default)
allowlist
open (requires allowFrom to include "*")
disabled
Allowlist field: channels.imessage.allowFrom.
Allowlist entries must identify senders: handles or static sender access groups (`accessGroup:
). Use channels.imessage.groupAllowFromfor chat targets such aschat_id:, chat_guid:, or chat_identifier:*; use channels.imessage.groupsfor numericchat_id` registry keys.
channels.imessage.groupPolicy controls group handling:
allowlist (default when configured)
open
disabled
Group sender allowlist: channels.imessage.groupAllowFrom.
groupAllowFrom entries can also reference static sender access groups (`accessGroup:
`).
Runtime fallback: if `groupAllowFrom` is unset, iMessage group sender checks use `allowFrom`; set `groupAllowFrom` when DM and group admission should differ.
Runtime note: if `channels.imessage` is completely missing, runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set).
with no configured patterns, mention gating cannot be enforced
Control commands from authorized senders can bypass mention gating in groups.
Per-group systemPrompt:
Each entry under channels.imessage.groups.* accepts an optional systemPrompt string. The value is injected into the agent’s system prompt on every turn that handles a message in that group. Resolution mirrors the per-group prompt resolution used by channels.whatsapp.groups:
Group-specific system prompt (`groups[”
“].systemPrompt): used when the specific group entry exists in the map **and** its systemPromptkey is defined. IfsystemPrompt is an empty string ("") the wildcard is suppressed and no system prompt is applied to that group. 2. **Group wildcard system prompt** (groups[”*“].systemPrompt): used when the specific group entry is absent from the map entirely, or when it exists but defines no systemPrompt` key.
```json5
{
channels: {
imessage: {
groupPolicy: "allowlist",
groupAllowFrom: ["+15555550123"],
groups: {
"*": { systemPrompt: "Use British spelling." },
"8421": {
requireMention: true,
systemPrompt: "This is the on-call rotation chat. Keep replies under 3 sentences.",
},
"9907": {
// explicit suppression: the wildcard "Use British spelling." does not apply here
systemPrompt: "",
},
},
},
},
}
```
Per-group prompts only apply to group messages — direct messages in this channel are unaffected.
DMs use direct routing; groups use group routing.
With default session.dmScope=main, iMessage DMs collapse into the agent main session.
Group sessions are isolated (`agent:
:imessage:group:
`).
- Replies route back to iMessage using originating channel/target metadata.
Group-ish thread behavior:
Some multi-participant iMessage threads can arrive with `is_group=false`.
If that `chat_id` is explicitly configured under `channels.imessage.groups`, OpenClaw treats it as group traffic (group gating + group session isolation).
Use SSH keys so both SSH and SCP are non-interactive.
Ensure the host key is trusted first (for example ssh [email protected]) so known_hosts is populated.
Multi-account pattern
iMessage supports per-account config under channels.imessage.accounts.
Each account can override fields such as cliPath, dbPath, allowFrom, groupPolicy, mediaMaxMb, history settings, and attachment root allowlists.
Direct-message history
Set channels.imessage.dmHistoryLimit to seed new direct-message sessions with recent decoded imsg history for that conversation. Use `channels.imessage.dms[”
“].historyLimitfor per-sender overrides, including0` to disable history for a sender.
iMessage DM history is fetched on demand from `imsg`. Leaving `dmHistoryLimit` unset disables global DM history seeding, but a positive per-sender `channels.imessage.dms["
“].historyLimit` still enables seeding for that sender.
inbound attachment ingestion is off by default — set channels.imessage.includeAttachments: true to forward photos, voice memos, video, and other attachments to the agent. With it disabled, attachment-only iMessages are dropped before reaching the agent and may produce no Inbound message log line at all.
remote attachment paths can be fetched via SCP when remoteHost is set
When imsg launch is running and openclaw channels status --probe reports privateApi.available: true, the message tool can use iMessage-native actions in addition to normal text sends.
{
channels: {
imessage: {
actions: {
reactions: true,
edit: true,
unsend: true,
reply: true,
sendWithEffect: true,
sendAttachment: true,
renameGroup: true,
setGroupIcon: true,
addParticipant: true,
removeParticipant: true,
leaveGroup: true,
polls: true,
},
},
},
}
Available actions
react: Add/remove iMessage tapbacks (messageId, emoji, remove). Supported tapbacks map to love, like, dislike, laugh, emphasize, and question.
reply: Send a threaded reply to an existing message (messageId, text or message, plus chatGuid, chatId, chatIdentifier, or to).
sendWithEffect: Send text with an iMessage effect (text or message, effect or effectId).
edit: Edit a sent message on supported macOS/private API versions (messageId, text or newText).
unsend: Retract a sent message on supported macOS/private API versions (messageId).
upload-file: Send media/files (buffer as base64 or a hydrated media/path/filePath, filename, optional asVoice). Legacy alias: sendAttachment.
renameGroup, setGroupIcon, addParticipant, removeParticipant, leaveGroup: Manage group chats when the current target is a group conversation.
poll: Create a native Apple Messages poll (pollQuestion, pollOption repeated 2 to 12 times, plus chatGuid, chatId, chatIdentifier, or to). Recipients on iOS/iPadOS/macOS 26+ see and vote on it natively; older OS versions get a “Sent a poll” text fallback. Requires selectors.pollPayloadMessage.
poll-vote: Vote on an existing poll (pollId or messageId, plus exactly one of pollOptionIndex, pollOptionId, or pollOptionText). Requires selectors.pollVoteMessage and the poll.vote RPC method.
Accepted inbound polls are rendered for the agent with the question, numbered option labels, vote counts, and the poll message ID needed by poll-vote.
Message IDs
Inbound iMessage context includes both short MessageSid values and full message GUIDs when available. Short IDs are scoped to the recent SQLite-backed reply cache and are checked against the current chat before use. If a short ID has expired or belongs to another chat, retry with the full MessageSidFull.
Capability detection
OpenClaw hides private API actions only when the cached probe status says the bridge is unavailable. If the status is unknown, actions remain visible and dispatch probes lazily so the first action can succeed after imsg launch without a separate manual status refresh.
Read receipts and typing
When the private API bridge is up, accepted inbound chats are marked read and direct chats show a typing bubble as soon as the turn is accepted, while the agent prepares context and generates. Disable read-marking with:
{
channels: {
imessage: {
sendReadReceipts: false,
},
},
}
Older imsg builds that pre-date the per-method capability list will gate off typing/read silently; OpenClaw logs a one-time warning per restart so the missing receipt is attributable.
Inbound tapbacks
OpenClaw subscribes to iMessage tapbacks and routes accepted reactions as system events instead of normal message text, so a user tapback does not trigger an ordinary reply loop.
Notification mode is controlled by channels.imessage.reactionNotifications:
"own" (default): notify only when users react to bot-authored messages.
"all": notify for all inbound tapbacks from authorized senders.
"off": ignore inbound tapbacks.
Per-account overrides use `channels.imessage.accounts.
.reactionNotifications`.
Approval reactions (👍 / 👎)
When approvals.exec.enabled or approvals.plugin.enabled is true and the request routes to iMessage, the gateway delivers an approval prompt natively and accepts a tapback to resolve it:
👍 (Like tapback) → allow-once
👎 (Dislike tapback) → deny
allow-always remains a manual fallback: send `/approve
allow-always` as a regular reply.
Reaction handling requires the reacting user’s handle to be an explicit approver. The approver list is read from channels.imessage.allowFrom (or `channels.imessage.accounts.
.allowFrom); add the user's phone number in E.164 form or their Apple ID email. The wildcard entry ”*“is honored but allows any sender to approve. The reaction shortcut intentionally bypassesreactionNotifications, dmPolicy, and groupAllowFrom` because the explicit-approver allowlist is the only gate that matters for approval resolution.
**Behavior change with this release:** When `channels.imessage.allowFrom` is non-empty, the `/approve
text command is now authorized against that approver list (not the broader DM allowlist). Senders permitted on the DM allowlist but not inallowFromwill receive an explicit denial. Add every operator who should be able to approve via/approve(and via reactions) toallowFromto preserve the previous behavior. WhenallowFromis empty the legacy "same-chat fallback" stays in effect and/approve` continues to authorize anyone the DM allowlist permits.
Operator notes:
- The reaction binding is stored both in memory (with TTL matched to the approval expiry) and in the gateway's persistent keyed store, so a tapback that lands shortly after a gateway restart still resolves the approval.
- Cross-device `is_from_me=true` tapbacks (the operator's own reaction on a paired Apple device) are intentionally ignored so the bot cannot self-approve.
- Legacy text-style tapbacks (`Liked "…"` plain text from very old Apple clients) cannot resolve approvals because they carry no message GUID; reaction resolution requires the structured tapback metadata that current macOS / iOS clients emit.
When a user types a command and a URL together — e.g. Dump https://example.com/article — Apple’s Messages app splits the send into two separate chat.db rows:
A text message ("Dump").
A URL-preview balloon ("https://...") with OG-preview images as attachments.
The two rows arrive at OpenClaw ~0.8-2.0 s apart on most setups. Without coalescing, the agent receives the command alone on turn 1, replies (often “send me the URL”), and only sees the URL on turn 2 — at which point the command context is already lost. This is Apple’s send pipeline, not anything OpenClaw or imsg introduces.
channels.imessage.coalesceSameSenderDms opts a DM into buffering consecutive same-sender rows. When imsg exposes the structural URL-preview marker balloon_bundle_id: "com.apple.messages.URLBalloonProvider" on one of the source rows, OpenClaw merges only that real split-send and keeps any other buffered rows as separate turns. On older imsg builds that emit no balloon metadata at all, OpenClaw cannot tell a split-send from separate sends, so it falls back to merging the bucket. That preserves the pre-metadata behavior rather than regressing `Dump
` split-sends into two turns. Group chats continue to dispatch per-message so multi-user turn structure is preserved.
Enable when:
You ship skills that expect command + payload in one message (dump, paste, save, queue, etc.).
Your users paste URLs alongside commands.
You can accept the added DM turn latency (see below).
Leave disabled when:
You need minimum command latency for single-word DM triggers.
All your flows are one-shot commands without payload follow-ups.
{
channels: {
imessage: {
coalesceSameSenderDms: true, // opt in (default: false)
},
},
}
With the flag on and no explicit messages.inbound.byChannel.imessage or global messages.inbound.debounceMs, the debounce window widens to 7000 ms (the legacy default is 0 ms — no debouncing). The wider window is required because Apple’s URL-preview split-send cadence can stretch to several seconds while Messages.app emits the preview row.
To tune the window yourself:
{
messages: {
inbound: {
byChannel: {
// 7000 ms covers observed Messages.app URL-preview delays.
imessage: 7000,
},
},
},
}
Precise merging needs current imsg payload metadata. When the URL row includes balloon_bundle_id, only that real split-send merges and other buffered rows stay separate. On older imsg builds that expose no balloon metadata, OpenClaw falls back to merging the buffered bucket so `Dump
split-sends are not regressed into two turns (interim back-compat, removed onceimsgcoalesces split-sends upstream). - **Added latency for DM messages.** With the flag on, every DM (including standalone control commands and single-text follow-ups) waits up to the debounce window before dispatching, in case a URL-preview row is coming. Group-chat messages keep instant dispatch. - **Merged output is bounded.** Merged text caps at 4000 chars with an explicit…[truncated]marker; attachments cap at 20; source entries cap at 10 (first-plus-latest retained beyond that). Every source GUID is tracked incoalescedMessageGuidsfor downstream telemetry. - **DM-only.** Group chats fall through to per-message dispatch so the bot stays responsive when multiple people are typing. - **Opt-in, per-channel.** Other channels (Telegram, WhatsApp, Slack, …) are unaffected. Legacy BlueBubbles configs that setchannels.bluebubbles.coalesceSameSenderDmsshould migrate that value tochannels.imessage.coalesceSameSenderDms`.
The “Flag on” column shows behavior on an imsg build that emits balloon_bundle_id. On older imsg builds that emit no balloon metadata at all, the rows below marked “Two turns” / “N turns” instead fall back to a legacy merge (one turn): OpenClaw cannot structurally tell a split-send from separate sends, so it preserves the pre-metadata merge. Precise separation activates once the build emits balloon metadata.
User composes
chat.db produces
Flag off (default)
Flag on + window (imsg emits balloon metadata)
Dump https://example.com (one send)
2 rows ~1 s apart
Two agent turns: “Dump” alone, then URL
One turn: merged text Dump https://example.com
Save this 📎image.jpg caption (attachment + text)
2 rows without URL balloon metadata
Two turns
Two turns after metadata is observed; one merged turn on old/pre-latch metadata-less sessions
/status (standalone command)
1 row
Instant dispatch
Wait up to window, then dispatch
URL pasted alone
1 row
Instant dispatch
Wait up to window, then dispatch
Text + URL sent as two deliberate separate messages, minutes apart
2 rows outside window
Two turns
Two turns (window expires between them)
Rapid flood (>10 small DMs inside window)
N rows without URL balloon metadata
N turns
N turns after metadata is observed; one bounded merged turn on old/pre-latch metadata-less sessions
Two people typing in a group chat
N rows from M senders
M+ turns (one per sender bucket)
M+ turns — group chats are not coalesced
Inbound recovery after a bridge or gateway restart
iMessage recovers messages missed while the gateway was down, and at the same time suppresses the stale “backlog bomb” Apple can flush after a Push recovery. The default behavior is always on, built on the inbound dedupe.
Replay dedupe. Every dispatched inbound message is recorded by its Apple GUID in persistent plugin state (imessage.inbound-dedupe), claimed at ingestion and committed after handling (released on a transient failure so it can retry). Anything already handled is dropped instead of dispatched twice. This is what lets recovery replay aggressively without per-message bookkeeping.
Downtime recovery. On startup the monitor remembers the last dispatched chat.db rowid (a persisted per-account cursor) and passes it to imsg watch.subscribe as since_rowid, so imsg replays the rows that landed while the gateway was down, then tails live. Replay is bounded to the most recent rows and to messages up to ~2 hours old, and the dedupe drops anything already handled.
Stale-backlog age fence. Rows above the startup boundary are genuinely live; one whose send date is more than ~15 minutes older than its arrival is the Push-flush backlog and is suppressed. Replayed rows (at or below the boundary) use the wider recovery window instead, so a recently-missed message is delivered while ancient history is not.
Recovery works over both local and remote cliPath setups, because since_rowid replay runs over the same imsg RPC connection. The difference is the window: when the gateway can read chat.db (local), it anchors the startup rowid boundary, caps the replay span, and delivers missed messages up to a couple of hours old. Over a remote SSH cliPath it cannot read the database, so the replay is uncapped and every row uses the live age fence — it still recovers recently-missed messages and still suppresses old backlog, just with the narrower live window. Run the gateway on the Messages Mac for the wider recovery window.
Suppressed backlog is logged at the default level, never silently dropped (the recovery flag shows which window applied):
imessage: suppressed stale inbound backlog account=
sent=
recovery=
(
suppressed since start)
### Migration
`channels.imessage.catchup.*` is deprecated — downtime recovery is now automatic and needs no config for new setups. Existing configs with `catchup.enabled: true` remain honored as a compatibility profile for the recovery replay window. Disabled catchup blocks (`enabled: false` or no `enabled: true`) are retired; `openclaw doctor --fix` removes those.
## Troubleshooting
imsg not found or RPC unsupported
Validate the binary and RPC support:
Terminal window
imsgrpc--help
imsgstatus--json
openclawchannelsstatus--probe
If probe reports RPC unsupported, update imsg. If private API actions are unavailable, run imsg launch in the logged-in macOS user session and probe again. If the Gateway is not running on macOS, use the Remote Mac over SSH setup above instead of the default local imsg path.
Messages send but inbound iMessages do not arrive
First prove whether the message reached the local Mac. If `chat.db` does not change, OpenClaw cannot receive the message even when `imsg status --json` reports a healthy bridge.
If phone-sent messages create no new rows, repair the macOS Messages and Apple Push layer before changing OpenClaw config. A one-shot service refresh is often enough:
Send a fresh iMessage from the phone and confirm a new `chat.db` row or `imsg watch` event before debugging OpenClaw sessions. Do not run this as a periodic bridge-relaunch loop; repeated `imsg launch` plus gateway restarts during active work can interrupt deliveries and strand in-flight channel runs.
Gateway is not running on macOS
The default `cliPath: "imsg"` must run on the Mac signed into Messages. On Linux or Windows, set `channels.imessage.cliPath` to a wrapper script that SSHes to that Mac and runs `imsg "$@"`.
#!/usr/bin/env bash
execssh-Tmessages-macimsg"$@"
Then run:
Terminal window
openclawchannelsstatus--probe--channelimessage
DMs are ignored
Check:
channels.imessage.dmPolicy
channels.imessage.allowFrom
pairing approvals (openclaw pairing list imessage)