iMessage on macOS — Cowork-native
When to use
Use this skill when the user asks to:
- Review / triage recent iMessages or SMS.
- Find messages that still need a response.
- Search history for a topic, person, or phrase.
- Pull a specific conversation's recent messages.
- Compute response-time statistics (e.g. "average reply time to Alice over the last 24 hours").
- Send a plain-text iMessage to an existing contact.
Architecture at a glance
Cowork agent (Linux sandbox) ~/Library (the user's Mac)
---------------------------- -------------------------
Writes request-<id>.json --> launchd watches control/requests/ -->
Reads response-<id>.json <-- claude-cowork-imessage-helper (wrapper) -->
helper.py (FDA-granted, read-only
copy of chat.db + AddressBook)
The Cowork sandbox cannot read ~/Library/Messages/chat.db directly — its
Bash tool runs in a Linux VM with no view of the Mac filesystem. A launchd
agent bridges the two sides: Claude drops a JSON request into the selected
Cowork folder, launchd fires a tiny signed wrapper binary that has Full Disk
Access, the wrapper execs helper.py, and helper writes the JSON response
back into the same folder where Claude can read it.
Sending goes through the same request/response bridge. Claude writes a
send_preview or send request. After nonce validation, the helper opens a
native macOS confirmation window showing the exact recipient and complete body;
Cancel is the keyboard default. Only an explicit Send click permits the helper
to call osascript.
Prerequisites — one-time setup
This plugin ships the helper source and install scripts bundled inside the
skill directory (alongside this SKILL.md):
install.sh/uninstall.sh(standard mode)install-hardened.sh/uninstall-hardened.sh(recommended mode)bootstrap.shcom.jeffhuber.claudecowork-imessage.plist.templatebin/imessage_helper.c(wrapper source)bin/helper.py(Python worker)bin/send_gate.py(send gate)bin/confirm_imessage_send.m(native confirmation source)
The user runs a one-time install into the Cowork folder they want to use as the request/response bridge. Order of operations the first time a user asks Claude to read iMessages:
Verify the user has selected a Cowork folder. If not, tell them to pick one (it will hold
control/andbin/). Call it the "bridge folder".Guide the user through these steps:
a. Run the bundled bootstrap script with the selected bridge folder. Use the script's actual path supplied by the installed plugin; do not derive it from
$0:"<installed-plugin-assets>/skills/imessage-review/bootstrap.sh" \ "<bridge folder>"b. In Terminal, choose the hardened install unless the user explicitly wants a no-sudo, same-user trust model:
cd "<bridge folder>" && ./install-hardened.sh # Alternative: ./install.shc. When the installer prints FDA instructions, guide the user through: System Settings → Privacy & Security → Full Disk Access → + → paste the exact wrapper path printed.
Run the
doctor.pycommand printed by the installer. Also verify<bridge folder>/control/{requests,responses}/,log.txt, and~/Library/LaunchAgents/com.jeffhuber.claudecowork-imessage.plistexist.
If any verification fails, tell the user exactly which step broke and show
the relevant line from control/log.txt.
Invoking the helper (read/analyze actions)
Write complete JSON to a hidden temporary file in
<bridge folder>/control/requests/, then atomically rename it to
request-<id>.json. Never write directly to the final name: launchd may fire
while a direct write is incomplete.
launchd will fire the helper within ~1 second (WatchPaths has a 1s
ThrottleInterval). Then poll
<bridge folder>/control/responses/response-<id>.json until it appears —
typically 2–5s total including the chat.db copy.
Use a UUID or timestamp for <id>. Always set id inside the request body
to the same string. Delete each response immediately after parsing it.
Whitelisted actions (anything else is rejected):
status — compatibility and installation checks
{"id": "abc", "action": "status", "params": {}}
Call this before the first message operation. Require protocol major version
1; fail closed with upgrade guidance on a future major mismatch.
review — triage recent messages
{"id": "abc", "action": "review", "params": {"days": 2}}
Response has three buckets: needs_reply, low_priority, and skip_summary
(summary only — skip-bucket message text is not returned).
search — find messages by substring
{"id": "abc", "action": "search",
"params": {"term": "dinner plans", "days": 30, "limit": 100}}
chat_history — recent messages in one thread
{"id": "abc", "action": "chat_history",
"params": {"chat": "Alice Example", "days": 14, "limit": 100}}
chat accepts a contact name (resolved via Contacts.app), a phone number
(any format — the helper matches the last 10 digits), or an email address.
response_stats — average reply time to one contact
{"id": "abc", "action": "response_stats",
"params": {"chat": "Alice Example", "hours": 24}}
Returns sample_size, avg_seconds, avg_human (e.g. "18.3m"),
median_seconds, min/max, and inbound/outbound counts.
contacts_lookup — find matching contacts
{"id": "abc", "action": "contacts_lookup", "params": {"name": "Alice"}}
send_preview — dry-run a send (no osascript, no chat.db)
{"id": "abc", "action": "send_preview",
"params": {"to": "+14155551234", "text": "Confirmed for 3pm.",
"service": "iMessage"}}
Response includes the validated recipient, the resolved contact name (if any),
the service, the text and its length, and whether the thread is on the
blocklist. send_preview is declarative — it does not touch chat.db and
does not send anything. Use it to confirm with the user before calling
send.
The response ALSO includes a send_nonce (a short opaque string) and
send_nonce_ttl_seconds (default 60). You MUST echo send_nonce back in
the subsequent send request's params. The helper binds the nonce to
the exact previewed (to, text, service) triple; a missing, stale, replayed,
or payload-mismatched nonce causes send to be refused.
{"id": "abc", "ok": true, "action": "send_preview",
"preview": {"to": "+14155551234", "resolved_name": "Alex",
"service": "iMessage", "text": "Confirmed for 3pm.",
"text_length": 18, "blocked": false},
"send_nonce": "Zk9...short-opaque-string",
"send_nonce_ttl_seconds": 60}
send — actually send the message
{"id": "abc", "action": "send",
"params": {"to": "+14155551234", "text": "Confirmed for 3pm.",
"service": "iMessage",
"send_nonce": "Zk9...short-opaque-string"}}
The send_nonce is the one returned by the preceding send_preview. The
to, text, and service must match the preview exactly — change any of
them and you'll need to send_preview again.
The helper writes text to a temporary UTF-8 file, shells out to
/usr/bin/osascript only after a native confirmation window shows the exact
phone/email, service, and complete body. The tempfile is always removed, even
on failure or cancellation.
Response on success includes sent: {to, resolved_name, service, text_length, sent_at}. On failure, the helper returns an error. Typical
causes: missing Automation permission for Messages (see "Sending messages"
below), a recipient that isn't reachable on the chosen service, or a
send-gate error ("send gate: ..." / "missing nonce; call send_preview first" / "send payload differs from preview").
Refusal rules baked into the helper — no way for Claude to override:
- Recipient must be an individual phone number or conservative ASCII email.
Names and all
chat...group identifiers are rejected. - Text must be 1–4000 chars and contain no C0 control characters other
than
\n,\r,\t. servicemust be"iMessage","SMS", or omitted (defaults to iMessage).- If the recipient matches an entry in
contacts/blocked_chats.txt,sendraisesValueErrorbefore calling osascript. sendrequires a fresh, payload-boundsend_nonceminted by a priorsend_previewwithinsend_nonce_ttl_seconds(default 60s). Skipping the preview step, or tampering with the body between preview and send, is rejected helper-side. (v0.4.0+)
Example request flow
# Pseudocode — actual tool calls are Write + Bash/Read against the
# Cowork-selected bridge folder, which is mounted at a path like
# /sessions/<session-id>/mnt/<folder-name>
import json, uuid, time, pathlib
root = pathlib.Path("<bridge folder>")
req_id = uuid.uuid4().hex[:12]
tmp = root / "control" / "requests" / f".request-{req_id}.json.tmp"
final = root / "control" / "requests" / f"request-{req_id}.json"
tmp.write_text(
json.dumps({"id": req_id, "action": "review", "params": {"days": 2}})
)
tmp.replace(final)
resp = root / "control" / "responses" / f"response-{req_id}.json"
for _ in range(30):
if resp.exists():
break
time.sleep(0.5)
try:
data = json.loads(resp.read_text())
finally:
resp.unlink(missing_ok=True)
Sending messages
Sending is an action on the helper, same bridge as read actions. The helper
uses AppleScript via /usr/bin/osascript to tell Messages.app to send the
message — no GUI clicks, no Computer Use, just a subprocess that returns in
under a second on average.
Recommended flow every time — preview, confirm, then send:
- Resolve the recipient. If the user said a name, run
contacts_lookupfirst. If there are multiple matches, surface them and ask. Use a raw phone number (any format — last 10 digits match) or email if the user prefers that. - Issue a
send_previewrequest. Show the user the resolved recipient name, the service (iMessage / SMS), the full text, andtext_length. Keep thesend_noncefrom the response. - Wait for explicit user approval. If the preview shows
blocked: true, tell the user and stop — don't prompt for approval to send anyway. - Issue the
sendrequest with the sameto/text/serviceand thesend_noncefrom step 2. If user approval takes longer thansend_nonce_ttl_seconds(default 60), re-runsend_previewto mint a fresh nonce. - Tell the user to inspect the independent native macOS dialog and click Send
only if its exact recipient and complete body are expected. Never automate
or bypass this click. Surface
sent.sent_atonly after success.
Why the helper instead of computer-use?
Computer-use driving Messages.app was slow (5–15s for the full click-type-
Return dance), flaky (autocomplete races, Send-as-SMS sheets, modal popups),
and inherently imprecise (pixel-level clicks on a UI that can change
between macOS releases). AppleScript's tell application "Messages" API
takes a recipient + service + body and dispatches the message directly. For
the same confirmation-safety model, we gate it behind send_preview +
explicit user OK before calling send.
Automation permission (one-time, separate from Full Disk Access)
The first time the helper calls osascript to send via Messages, macOS
will show an Automation prompt: "claude-cowork-imessage-helper wants to control
Messages." Click OK. After that, the grant lives under:
System Settings → Privacy & Security → Automation → claude-cowork-imessage-helper → Messages (toggle on)
If the wrapper binary is rebuilt with a different CDHash, the grant needs to be removed and re-added (same as Full Disk Access).
Common pitfalls
- FDA not granted yet. First request returns
sqlite3.OperationalError: unable to open database fileinlog.txt. Tell the user to grant FDA to the wrapper binary (exact path is ininstall.shoutput). - Wrapper re-signed. If the user rebuilt the wrapper after a content change, the FDA grant needs to be removed and re-added (System Settings updates the identity automatically, but a stale grant toggled ON won't apply).
- Ambiguous contact name.
response_statswithchat: "Alex"resolves via the first contact whose name contains "Alex" — may not be the one intended. Fall back to a phone number if the user has multiple Alexes. - Group chats.
chat_identifierfor groups looks likechat1234567…. Thechat_historyaction accepts these directly if you already have one from a priorreviewresponse. - Messages still decoding as empty. The
attributedBodyparser is heuristic. If a thread's messages come back blank, checklog.txt— the helper logs only failure metadata and byte counts, never raw blob bytes. - Wrong folder selected. If the user selects a different Cowork folder
after installing, launchd is still watching the original bridge folder.
Re-run
install.shfrom the new folder to point launchd at it.
Redaction and privacy
The helper redacts before writing the response:
- 2FA / verification codes near words like "code", "verification", "OTP".
- Credit-card-like digit runs (13–19 digits).
- US SSN patterns.
Chats listed in contacts/blocked_chats.txt are dropped entirely — their
text never enters the response JSON, which means it never enters Claude's
context window. Users should add therapist / attorney / financial advisor
threads here.
Files shipped with this skill
| Path | Role |
|---|---|
SKILL.md |
This file. |
install.sh |
One-time setup: builds + signs wrapper, installs plist, bootstraps launchd. |
install-hardened.sh |
Recommended setup: root-owned code and default-deny read allowlist. |
uninstall.sh |
Removes the launchd agent. Leaves files + FDA grant. |
uninstall-hardened.sh |
Removes only Claude's root-owned helper and LaunchAgent. |
com.jeffhuber.claudecowork-imessage.plist.template |
launchd agent template. Filled in by install.sh and copied to ~/Library/LaunchAgents/. |
bin/imessage_helper.c |
Tiny hardened wrapper. FDA is granted to this. Ignores argv, sanitizes environment, execs helper.py. |
bin/helper.py |
Python helper. Scans control/requests/, dispatches actions, writes control/responses/response-*.json. |
bin/send_gate.py |
Helper-side preview/confirm gate. Mints single-use nonces on send_preview, consumes them on send. Persists under <bridge>/nonces/. (v0.4.0+) |
bin/confirm_imessage_send.m |
Native, fail-closed send confirmation UI. |
tools/doctor.py |
Non-destructive install and permission diagnostics. |
tools/configure_allowlist.py |
Narrow sudo-backed hardened policy editor. |
Files created at the user's bridge folder (after install)
| Path | Role |
|---|---|
contacts/blocked_chats.txt |
User-maintained blocklist of sensitive chats. |
control/requests/ |
Agent writes request JSON here. Watched by launchd. |
control/responses/ |
Helper writes response JSON here. Agent reads. |
control/log.txt |
Private, rotating helper diagnostics. No message bodies. |
bin/claude-cowork-imessage-helper |
Standard-mode wrapper. Hardened mode stores it under the root-owned code root. |
nonces/ |
Short-lived per-nonce files bound to previewed sends. Created on first preview; TTL-reaped on every helper run. (v0.4.0+) |