docs/channels/imessage.md
Gateway-downtime catchup is opt-in. When enabled (channels.imessage.catchup.enabled: true), the gateway replays inbound messages that landed in chat.db while it was offline (crash, restart, Mac sleep) on next startup. Disabled by default — see Catching up after gateway downtime. Closes openclaw#78649.
</Note>
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.
brew install steipete/tap/imsg
imsg rpc --help
imsg launch
openclaw channels status --probe
</Step>
<Step title="Configure OpenClaw">
{
channels: {
imessage: {
enabled: true,
cliPath: "/usr/local/bin/imsg",
dbPath: "/Users/user/Library/Messages/chat.db",
},
},
}
</Step>
<Step title="Start gateway">
openclaw gateway
</Step>
<Step title="Approve first DM pairing (default dmPolicy)">
openclaw pairing list imessage
openclaw pairing approve imessage <CODE>
Pairing requests expire after 1 hour.
</Step>
</Steps>
#!/usr/bin/env bash
exec ssh -T gateway-host imsg "$@"
Recommended config when attachments are enabled:
{
channels: {
imessage: {
enabled: true,
cliPath: "~/.openclaw/scripts/imsg-ssh",
remoteHost: "user@gateway-host", // used for SCP attachment fetches
includeAttachments: true,
// Optional: override allowed attachment roots.
// Defaults include /Users/*/Library/Messages/Attachments
attachmentRoots: ["/Users/*/Library/Messages/Attachments"],
remoteAttachmentRoots: ["/Users/*/Library/Messages/Attachments"],
},
},
}
If `remoteHost` is not set, OpenClaw attempts to auto-detect it by parsing the SSH wrapper script.
`remoteHost` must be `host` or `user@host` (no spaces or SSH options).
OpenClaw uses strict host-key checking for SCP, so the relay host key must already exist in `~/.ssh/known_hosts`.
Attachment paths are validated against allowed roots (`attachmentRoots` / `remoteAttachmentRoots`).
imsg.imsg (Messages DB access).imsg chats --limit 1
# or
imsg send <handle> "test"
imsg ships in two operational modes:
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.imsg injects a helper dylib into Messages.app to call internal IMCore functions. This is what unlocks react, edit, unsend, reply (threaded), sendWithEffect, 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 intoMessages.app.imsg launchrefuses 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.
Treat this as a deliberate operational choice, not a default. If your threat model can't tolerate SIP being off, bundled iMessage is limited to basic mode — text and media send/receive only, no reactions / edit / unsend / effects / group ops. </Warning>
Install (or upgrade) imsg on the Mac that runs Messages.app:
brew install steipete/tap/imsg
imsg --version
imsg status --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. This is macOS-version-specific because the underlying Apple requirement depends on the OS and hardware:
csrutil disable, restart.csrutil disable, restart.csrutil disable. Virtual-machine setups follow a separate flow — take a VM snapshot first.imagent private-entitlement checks have tightened further; imsg may need an updated build to keep up. If imsg launch injection or specific selectors start returning false after a macOS major upgrade, check imsg's release notes before assuming the SIP step succeeded.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:
imsg launch
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:
openclaw channels status --probe
The iMessage entry should report works, and imsg status --json | jq '.selectors' should show retractMessagePart: true plus whichever edit / typing / read selectors your macOS build exposes. The OpenClaw plugin per-method gating in actions.ts only advertises actions whose underlying selector is true, so the action surface you see in the agent's tool list reflects what the bridge can actually do on this host.
If openclaw channels status --probe reports the channel as works but specific actions throw "iMessage <action> requires the imsg private API bridge" at dispatch time, run imsg launch again — the helper can fall out (Messages.app restart, OS update, etc.) and the cached available: 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.react, edit, unsend, reply, sendWithEffect, and group ops from the action surface (per the per-method capability gate).- `pairing` (default)
- `allowlist`
- `open` (requires `allowFrom` to include `"*"`)
- `disabled`
Allowlist field: `channels.imessage.allowFrom`.
Allowlist entries can be handles, static sender access groups (`accessGroup:<name>`), or chat targets (`chat_id:*`, `chat_guid:*`, `chat_identifier:*`).
- `allowlist` (default when configured)
- `open`
- `disabled`
Group sender allowlist: `channels.imessage.groupAllowFrom`.
`groupAllowFrom` entries can also reference static sender access groups (`accessGroup:<name>`).
Runtime fallback: if `groupAllowFrom` is unset, iMessage group sender checks fall back to `allowFrom` when available.
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).
<Warning>
Group routing has **two** allowlist gates running back-to-back, and both must pass:
1. **Sender / chat-target allowlist** (`channels.imessage.groupAllowFrom`) — handle, `chat_guid`, `chat_identifier`, or `chat_id`.
2. **Group registry** (`channels.imessage.groups`) — with `groupPolicy: "allowlist"`, this gate requires either a `groups: { "*": { ... } }` wildcard entry (sets `allowAll = true`), or an explicit per-`chat_id` entry under `groups`.
If gate 2 has nothing in it, every group message is dropped. The plugin emits two `warn`-level signals at the default log level:
- one-time per account at startup: `imessage: groupPolicy="allowlist" but channels.imessage.groups is empty for account "<id>"`
- one-time per `chat_id` at runtime: `imessage: dropping group message from chat_id=<id> ...`
DMs continue to work because they take a different code path.
Minimum config to keep groups flowing under `groupPolicy: "allowlist"`:
```json5
{
channels: {
imessage: {
groupPolicy: "allowlist",
groupAllowFrom: ["+15555550123"],
groups: { "*": { "requireMention": true } },
},
},
}
```
If those `warn` lines appear in the gateway log, gate 2 is dropping — add the `groups` block.
</Warning>
Mention gating for groups:
- iMessage has no native mention metadata
- mention detection uses regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`)
- 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`:
1. **Group-specific system prompt** (`groups["<chat_id>"].systemPrompt`): used when the specific group entry exists in the map **and** its `systemPrompt` key is defined. If `systemPrompt` 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.
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).
Legacy iMessage chats can also be bound to ACP sessions.
Fast operator flow:
/acp spawn codex --bind here inside the DM or allowed group chat./new and /reset reset the same bound ACP session in place./acp close closes the ACP session and removes the binding.Configured persistent bindings are supported through top-level bindings[] entries with type: "acp" and match.channel: "imessage".
match.peer.id can use:
+15555550123 or [email protected]chat_id:<id> (recommended for stable group bindings)chat_guid:<guid>chat_identifier:<identifier>Example:
{
agents: {
list: [
{
id: "codex",
runtime: {
type: "acp",
acp: { agent: "codex", backend: "acpx", mode: "persistent" },
},
},
],
},
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "imessage",
accountId: "default",
peer: { kind: "group", id: "chat_id:123" },
},
acp: { label: "codex-group" },
},
],
}
See ACP Agents for shared ACP binding behavior.
Typical flow:
1. Create/sign in a dedicated macOS user.
2. Sign into Messages with the bot Apple ID in that user.
3. Install `imsg` in that user.
4. Create SSH wrapper so OpenClaw can run `imsg` in that user context.
5. Point `channels.imessage.accounts.<id>.cliPath` and `.dbPath` to that user profile.
First run may require GUI approvals (Automation + Full Disk Access) in that bot user session.
- gateway runs on Linux/VM
- iMessage + `imsg` runs on a Mac in your tailnet
- `cliPath` wrapper uses SSH to run `imsg`
- `remoteHost` enables SCP attachment fetches
Example:
```json5
{
channels: {
imessage: {
enabled: true,
cliPath: "~/.openclaw/scripts/imsg-ssh",
remoteHost: "[email protected]",
includeAttachments: true,
dbPath: "/Users/bot/Library/Messages/chat.db",
},
},
}
```
```bash
#!/usr/bin/env bash
exec ssh -T [email protected] imsg "$@"
```
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.
Each account can override fields such as `cliPath`, `dbPath`, `allowFrom`, `groupPolicy`, `mediaMaxMb`, history settings, and attachment root allowlists.
- `chat_id:123` (recommended for stable routing)
- `chat_guid:...`
- `chat_identifier:...`
Handle targets are also supported:
- `imessage:+1555...`
- `sms:+1555...`
- `[email protected]`
```bash
imsg chats --limit 20
```
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,
},
},
},
}
```json5
{
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.
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.<id>.reactionNotifications`.
iMessage allows channel-initiated config writes by default (for /config set|unset when commands.config: true).
Disable:
{
channels: {
imessage: {
configWrites: false,
},
},
}
<a id="coalescing-split-send-dms-command--url-in-one-composition"></a>
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:
"Dump")."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 merging consecutive same-sender rows into a single agent turn. Group chats continue to dispatch per-message so multi-user turn structure is preserved.
- You ship skills that expect `command + payload` in one message (dump, paste, save, queue, etc.).
- Your users paste URLs, images, or long content 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.
With the flag on and no explicit `messages.inbound.byChannel.imessage`, the debounce window widens to **2500 ms** (the legacy default is 0 ms — no debouncing). The wider window is required because Apple's split-send cadence of 0.8-2.0 s does not fit in a tighter default.
To tune the window yourself:
```json5
{
messages: {
inbound: {
byChannel: {
// 2500 ms works for most setups; raise to 4000 ms if your Mac is
// slow or under memory pressure (observed gap can stretch past 2 s
// then).
imessage: 2500,
},
},
},
}
```
| User composes | chat.db produces | Flag off (default) | Flag on + 2500 ms window |
|---|---|---|---|
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 | Two turns (attachment dropped on merge) | One turn: text + image preserved |
/status (standalone command) | 1 row | Instant dispatch | Wait up to window, then dispatch |
| URL pasted alone | 1 row | Instant dispatch | Instant dispatch (only one entry in bucket) |
| 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 | N turns | One turn, bounded output (first + latest, text/attachment caps applied) |
| 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 |
When the gateway is offline (crash, restart, Mac sleep, machine off), imsg watch resumes from the current chat.db state once the gateway comes back up — anything that arrived during the gap is, by default, never seen. Catchup replays those messages on the next startup so the agent does not silently miss inbound traffic.
Catchup is disabled by default. Enable it per channel:
channels: {
imessage: {
catchup: {
enabled: true, // master switch (default: false)
maxAgeMinutes: 120, // skip rows older than now - 2h (default: 120, clamp 1..720)
perRunLimit: 50, // max rows replayed per startup (default: 50, clamp 1..500)
firstRunLookbackMinutes: 30, // first run with no cursor: look back 30 min (default: 30)
maxFailureRetries: 10, // give up on a wedged guid after 10 dispatch failures (default: 10)
},
},
}
One pass per monitorIMessageProvider startup, sequenced as imsg launch ready → watch.subscribe → performIMessageCatchup → live dispatch loop. Catchup itself uses chats.list + per-chat messages.history against the same JSON-RPC client used by imsg watch. Anything that arrives during the catchup pass flows through live dispatch normally; the existing inbound-dedupe cache absorbs any overlap with replayed rows.
Each replayed row is fed through the live dispatch path (evaluateIMessageInbound + dispatchInboundMessage), so allowlists, group policy, debouncer, echo cache, and read receipts behave identically on replayed and live messages.
Catchup keeps a per-account cursor at <openclawStateDir>/imessage/catchup/<account>__<hash>.json (the OpenClaw state dir defaults to ~/.openclaw, overridable with OPENCLAW_STATE_DIR):
{
"lastSeenMs": 1717900800000,
"lastSeenRowid": 482910,
"updatedAt": 1717900801234,
"failureRetries": { "<guid>": 1 }
}
maxFailureRetries consecutive throws against the same guid, catchup logs a warn and force-advances the cursor past the wedged message so subsequent startups can make progress.skippedGivenUp in the run summary.imessage catchup: replayed=N skippedFromMe=… skippedGivenUp=… failed=… givenUp=… fetchedCount=…
imessage catchup: giving up on guid=<guid> after <N> failures; advancing cursor past it
imessage catchup: fetched <X> rows across chats, capped to perRunLimit=<Y>
A WARN ... capped to perRunLimit line means a single startup did not drain the full backlog. Raise perRunLimit (max 500) if your gaps regularly exceed the default 50-row pass.
firstRunLookbackMinutes initial window can dispatch surprising old context on first enable.When you turn catchup on, the first startup with no cursor only looks back firstRunLookbackMinutes (30 min default), not the full maxAgeMinutes window — this avoids replaying a long history of pre-enable messages.
```bash
imsg rpc --help
imsg status --json
openclaw channels status --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.
#!/usr/bin/env bash
exec ssh -T messages-mac imsg "$@"
Then run:
openclaw channels status --probe --channel imessage
- `channels.imessage.dmPolicy`
- `channels.imessage.allowFrom`
- pairing approvals (`openclaw pairing list imessage`)
- `channels.imessage.groupPolicy`
- `channels.imessage.groupAllowFrom`
- `channels.imessage.groups` allowlist behavior
- mention pattern configuration (`agents.list[].groupChat.mentionPatterns`)
- `channels.imessage.remoteHost`
- `channels.imessage.remoteAttachmentRoots`
- SSH/SCP key auth from the gateway host
- host key exists in `~/.ssh/known_hosts` on the gateway host
- remote path readability on the Mac running Messages
```bash
imsg chats --limit 1
imsg send <handle> "test"
```
Confirm Full Disk Access + Automation are granted for the process context that runs OpenClaw/`imsg`.