Tools: Making iMessage Reliable with OpenClaw: 3 Problems and How We Fixed Them

Tools: Making iMessage Reliable with OpenClaw: 3 Problems and How We Fixed Them

The Setup ## Problem 1: Messages Delayed Up to 5 Minutes When Idle ## Problem 2: Images Sent via iMessage Fail with "Path Not Allowed" ## Problem 3: macOS Updates Silently Revoke Full Disk Access ## The Post-Update Checklist ## Should OpenClaw Fix These Upstream? ## Lessons Learned OpenClaw can use iMessage as a communication channel — you text your AI agent, it texts you back. Sounds simple, but running it 24/7 on a Mac mini revealed three reliability issues that took weeks to fully diagnose. Here's what went wrong and how we fixed each one. OpenClaw's iMessage plugin works by watching ~/Library/Messages/chat.db via filesystem events (FSEvents). When a new message arrives, macOS writes to chat.db, the watcher detects the change, and the gateway processes the message. In theory, this is instant. In practice, it breaks in three distinct ways. Symptom: You send a message, it shows "Delivered" on your phone, but the agent doesn't respond for 3-5 minutes. Then suddenly it processes everything at once. Root Cause: macOS power management coalesces FSEvents for background processes. Even with ProcessType=Interactive in the LaunchAgent plist and caffeinate running, the kernel still batches vnode events on chat.db during low-activity periods. The imsg rpc subprocess watches the file, but macOS decides "this process hasn't been active, let's batch up those file notifications." Why It's Tricky: The message is already in chat.db — it's the notification that's delayed, not the message itself. So everything works perfectly during active use, but fails silently when the machine is idle. Fix: A polling script that checks chat.db every 15 seconds and touches the file when new rows appear, generating a fresh FSEvent: Why Node.js instead of bash? We tried a bash version first, but launchd-spawned /bin/bash processes don't inherit Full Disk Access (TCC). The stat command works, but sqlite3 gets "authorization denied". Using /opt/homebrew/bin/node works because it inherits FDA from the same TCC grant as the gateway. Deployment: Run as a LaunchAgent with KeepAlive: true: Survives OpenClaw updates? Yes — it's a standalone launchd job. Symptom: The agent tries to send an image that was received via iMessage, but gets "Local media path is not under an allowed directory." The image exists at ~/Library/Messages/Attachments/... but OpenClaw's media sandboxing blocks it. Root Cause: OpenClaw's buildMediaLocalRoots() function defines which directories are allowed for media file access. It includes the workspace, temp directories, and sandboxes — but not ~/Library/Messages/Attachments/. When the agent tries to forward or process an image received via iMessage, the path is rejected. Fix: A patch script that adds the Messages attachment directory to the allowed roots: Survives OpenClaw updates? No — the compiled JS files are overwritten. You must re-run this after every update. Symptom: iMessage stops working entirely. No messages received, no errors in the gateway log that make sense. The agent appears online but is deaf. Root Cause: macOS system updates (and sometimes minor security patches) can reset TCC (Transparency, Consent, and Control) permissions. When this happens, the imsg binary loses Full Disk Access, which means it can't read ~/Library/Messages/chat.db. The gateway logs show: In our logs, this happened on Feb 13 and Feb 24, 2026 — both times correlating with macOS updates. Fix: Manual, unfortunately. Make sure imsg (or Terminal / iTerm, whichever runs your gateway) has FDA enabled. Toggle it off and on if it looks correct but isn't working. Survives OpenClaw updates? Yes — TCC permissions are system-level. But macOS updates can reset them. Every time you run npm update -g openclaw, do this: After macOS updates, also check Full Disk Access permissions. Problem 1 (FSEvents coalescing) is a macOS kernel behavior — hard to fix in OpenClaw itself. The poller is the right workaround. OpenClaw could ship it as an optional component. Problem 2 (attachment path) is a clear bug/oversight. ~/Library/Messages/Attachments/ should be in the default allowed roots when the iMessage plugin is enabled. This is a one-line fix upstream. Problem 3 (TCC reset) is Apple's problem. Nothing OpenClaw can do except maybe detect it and log a clearer error message. "Works on my machine" isn't enough for always-on agents. These bugs only appear after days of continuous operation or after system updates. You need to run your agent 24/7 for weeks to find them. macOS is not designed for headless servers. Power management, TCC, FSEvents coalescing — they all assume a human is sitting in front of the screen. Running an AI agent on a Mac mini requires fighting the OS at every level. Keep a patch directory. We maintain ~/.openclaw/autopatch/ with scripts and a README documenting every patch. When an update lands, we run them all. It's not elegant, but it's reliable. Log everything. The poller logs every touch it performs. The gateway logs every permission error. Without these, we'd still be debugging "why didn't my message go through?" This article was originally published on claw-stack.com. We're building an open-source AI agent runtime — check out the docs or GitHub. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to ? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse COMMAND_BLOCK:

#!/usr/bin/env node
// imsg-poller.mjs — Polls chat.db for new messages and wakes FSEvents watcher const CHATDB = join(homedir(), 'Library/Messages/chat.db');
const INTERVAL = 15000; // 15 seconds function getMaxRowid() { try { return execSync( `/usr/bin/sqlite3 "${CHATDB}" "SELECT MAX(ROWID) FROM message;"`, { timeout: 5000, encoding: 'utf8' } ).trim() || '0'; } catch { return '0'; }
} let lastRowid = getMaxRowid();
if (lastRowid === '0') { console.error('ERROR: Cannot read chat.db — check Full Disk Access'); process.exit(1);
} console.log(`imsg-poller started. ROWID: ${lastRowid}, interval: ${INTERVAL}ms`); setInterval(() => { const current = getMaxRowid(); if (current !== '0' && current !== lastRowid) { console.log(`New message (ROWID ${lastRowid} -> ${current}), touching chat.db`); try { const now = new Date(); utimesSync(CHATDB, now, now); } catch (e) { console.error(`touch failed: ${e.message}`); } lastRowid = current; }
}, INTERVAL); COMMAND_BLOCK:
#!/usr/bin/env node
// imsg-poller.mjs — Polls chat.db for new messages and wakes FSEvents watcher const CHATDB = join(homedir(), 'Library/Messages/chat.db');
const INTERVAL = 15000; // 15 seconds function getMaxRowid() { try { return execSync( `/usr/bin/sqlite3 "${CHATDB}" "SELECT MAX(ROWID) FROM message;"`, { timeout: 5000, encoding: 'utf8' } ).trim() || '0'; } catch { return '0'; }
} let lastRowid = getMaxRowid();
if (lastRowid === '0') { console.error('ERROR: Cannot read chat.db — check Full Disk Access'); process.exit(1);
} console.log(`imsg-poller started. ROWID: ${lastRowid}, interval: ${INTERVAL}ms`); setInterval(() => { const current = getMaxRowid(); if (current !== '0' && current !== lastRowid) { console.log(`New message (ROWID ${lastRowid} -> ${current}), touching chat.db`); try { const now = new Date(); utimesSync(CHATDB, now, now); } catch (e) { console.error(`touch failed: ${e.message}`); } lastRowid = current; }
}, INTERVAL); COMMAND_BLOCK:
#!/usr/bin/env node
// imsg-poller.mjs — Polls chat.db for new messages and wakes FSEvents watcher const CHATDB = join(homedir(), 'Library/Messages/chat.db');
const INTERVAL = 15000; // 15 seconds function getMaxRowid() { try { return execSync( `/usr/bin/sqlite3 "${CHATDB}" "SELECT MAX(ROWID) FROM message;"`, { timeout: 5000, encoding: 'utf8' } ).trim() || '0'; } catch { return '0'; }
} let lastRowid = getMaxRowid();
if (lastRowid === '0') { console.error('ERROR: Cannot read chat.db — check Full Disk Access'); process.exit(1);
} console.log(`imsg-poller started. ROWID: ${lastRowid}, interval: ${INTERVAL}ms`); setInterval(() => { const current = getMaxRowid(); if (current !== '0' && current !== lastRowid) { console.log(`New message (ROWID ${lastRowid} -> ${current}), touching chat.db`); try { const now = new Date(); utimesSync(CHATDB, now, now); } catch (e) { console.error(`touch failed: ${e.message}`); } lastRowid = current; }
}, INTERVAL); CODE_BLOCK:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict> <key>Label</key> <string>ai.openclaw.imsg-poller</string> <key>ProgramArguments</key> <array> <string>/opt/homebrew/bin/node</string> <string>/path/to/imsg-poller.mjs</string> </array> <key>RunAtLoad</key><true/> <key>KeepAlive</key><true/> <key>EnvironmentVariables</key> <dict> <key>HOME</key><string>/Users/youruser</string> </dict> <key>ThrottleInterval</key><integer>10</integer>
</dict>
</plist> CODE_BLOCK:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict> <key>Label</key> <string>ai.openclaw.imsg-poller</string> <key>ProgramArguments</key> <array> <string>/opt/homebrew/bin/node</string> <string>/path/to/imsg-poller.mjs</string> </array> <key>RunAtLoad</key><true/> <key>KeepAlive</key><true/> <key>EnvironmentVariables</key> <dict> <key>HOME</key><string>/Users/youruser</string> </dict> <key>ThrottleInterval</key><integer>10</integer>
</dict>
</plist> CODE_BLOCK:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict> <key>Label</key> <string>ai.openclaw.imsg-poller</string> <key>ProgramArguments</key> <array> <string>/opt/homebrew/bin/node</string> <string>/path/to/imsg-poller.mjs</string> </array> <key>RunAtLoad</key><true/> <key>KeepAlive</key><true/> <key>EnvironmentVariables</key> <dict> <key>HOME</key><string>/Users/youruser</string> </dict> <key>ThrottleInterval</key><integer>10</integer>
</dict>
</plist> COMMAND_BLOCK:
#!/usr/bin/env bash
# patch-imessage-attachments.sh
# Adds ~/Library/Messages/Attachments to allowed media roots
# Re-run after every `npm update -g openclaw` DIST="/opt/homebrew/lib/node_modules/openclaw/dist" patched=0
for f in "$DIST"/ir-*.js; do [ -f "$f" ] || continue if grep -q "buildMediaLocalRoots" "$f" && \ ! grep -q "Messages/Attachments" "$f"; then sed -i '' 's|path.join(resolvedStateDir, "sandboxes")|path.join(resolvedStateDir, "sandboxes"),\n\t\tpath.join(os.homedir(), "Library/Messages/Attachments")|' "$f" echo "Patched: $(basename $f)" patched=$((patched + 1)) fi
done echo "Done. Patched: $patched files"
echo "Run: openclaw gateway restart" COMMAND_BLOCK:
#!/usr/bin/env bash
# patch-imessage-attachments.sh
# Adds ~/Library/Messages/Attachments to allowed media roots
# Re-run after every `npm update -g openclaw` DIST="/opt/homebrew/lib/node_modules/openclaw/dist" patched=0
for f in "$DIST"/ir-*.js; do [ -f "$f" ] || continue if grep -q "buildMediaLocalRoots" "$f" && \ ! grep -q "Messages/Attachments" "$f"; then sed -i '' 's|path.join(resolvedStateDir, "sandboxes")|path.join(resolvedStateDir, "sandboxes"),\n\t\tpath.join(os.homedir(), "Library/Messages/Attachments")|' "$f" echo "Patched: $(basename $f)" patched=$((patched + 1)) fi
done echo "Done. Patched: $patched files"
echo "Run: openclaw gateway restart" COMMAND_BLOCK:
#!/usr/bin/env bash
# patch-imessage-attachments.sh
# Adds ~/Library/Messages/Attachments to allowed media roots
# Re-run after every `npm update -g openclaw` DIST="/opt/homebrew/lib/node_modules/openclaw/dist" patched=0
for f in "$DIST"/ir-*.js; do [ -f "$f" ] || continue if grep -q "buildMediaLocalRoots" "$f" && \ ! grep -q "Messages/Attachments" "$f"; then sed -i '' 's|path.join(resolvedStateDir, "sandboxes")|path.join(resolvedStateDir, "sandboxes"),\n\t\tpath.join(os.homedir(), "Library/Messages/Attachments")|' "$f" echo "Patched: $(basename $f)" patched=$((patched + 1)) fi
done echo "Done. Patched: $patched files"
echo "Run: openclaw gateway restart" CODE_BLOCK:
permissionDenied(path: "~/Library/Messages/chat.db", underlying: authorization denied (code: 23)) CODE_BLOCK:
permissionDenied(path: "~/Library/Messages/chat.db", underlying: authorization denied (code: 23)) CODE_BLOCK:
permissionDenied(path: "~/Library/Messages/chat.db", underlying: authorization denied (code: 23)) CODE_BLOCK:
grep "permissionDenied" ~/.openclaw/logs/gateway.err.log | tail -5 CODE_BLOCK:
grep "permissionDenied" ~/.openclaw/logs/gateway.err.log | tail -5 CODE_BLOCK:
grep "permissionDenied" ~/.openclaw/logs/gateway.err.log | tail -5 COMMAND_BLOCK:
/opt/homebrew/bin/imsg chats --limit 1 # Should return your most recent chat, not an error COMMAND_BLOCK:
/opt/homebrew/bin/imsg chats --limit 1 # Should return your most recent chat, not an error COMMAND_BLOCK:
/opt/homebrew/bin/imsg chats --limit 1 # Should return your most recent chat, not an error CODE_BLOCK:
openclaw gateway restart CODE_BLOCK:
openclaw gateway restart CODE_BLOCK:
openclaw gateway restart COMMAND_BLOCK:
# 1. Re-apply patches (overwritten by update)
bash ~/.openclaw/autopatch/patch-imessage-attachments.sh # 2. Restart gateway
openclaw gateway restart # 3. Verify iMessage works
/opt/homebrew/bin/imsg chats --limit 1 COMMAND_BLOCK:
# 1. Re-apply patches (overwritten by update)
bash ~/.openclaw/autopatch/patch-imessage-attachments.sh # 2. Restart gateway
openclaw gateway restart # 3. Verify iMessage works
/opt/homebrew/bin/imsg chats --limit 1 COMMAND_BLOCK:
# 1. Re-apply patches (overwritten by update)
bash ~/.openclaw/autopatch/patch-imessage-attachments.sh # 2. Restart gateway
openclaw gateway restart # 3. Verify iMessage works
/opt/homebrew/bin/imsg chats --limit 1 - Check the gateway error log: - If you see code: 23, go to:
System Settings → Privacy & Security → Full Disk Access - "Works on my machine" isn't enough for always-on agents. These bugs only appear after days of continuous operation or after system updates. You need to run your agent 24/7 for weeks to find them.
- macOS is not designed for headless servers. Power management, TCC, FSEvents coalescing — they all assume a human is sitting in front of the screen. Running an AI agent on a Mac mini requires fighting the OS at every level.
- Keep a patch directory. We maintain ~/.openclaw/autopatch/ with scripts and a README documenting every patch. When an update lands, we run them all. It's not elegant, but it's reliable.
- Log everything. The poller logs every touch it performs. The gateway logs every permission error. Without these, we'd still be debugging "why didn't my message go through?"