iMessage Helper Bridge Protocol
August 16, 2026 · View on GitHub
This document describes the JSON request/response protocol used between the local iMessage MCP server and the macOS helper.
Protocol version: 1.2
Clients should call status before their first message operation and require a
compatible protocol_version. Minor versions add backward-compatible actions or
fields; a future major-version mismatch must fail closed with upgrade guidance.
Protocol history:
1.2— adds the manager-onlylist_chatsaction, the bridge role table below, and thebridge_role/allowed_actionsfields instatus.1.1—statusaction and protocol compatibility reporting.
Bridge Roles
Every bridge has a role. The worker reads it from IMESSAGE_BRIDGE_ROLE
(set by the wrapper; the DIY installers set nothing, which means host) and
enforces the table below inside the worker, before any database is opened.
Hiding an action in a host, plugin, or app layer is not sufficient and is not
relied on. Unknown role values fail closed: no action is served.
| Action | host | manager | Returns message bodies |
|---|---|---|---|
status | yes | yes | no |
review, search, chat_history | yes | no | yes |
response_stats | yes | no | no |
contacts_lookup | yes (policy-filtered) | yes | no |
send_preview, send | yes | no | draft only |
list_chats | no | yes | no |
A host bridge is what an AI host talks to. A manager bridge is operated by
a local management tool for policy discovery and diagnostics; it never serves a
body-returning action, and a host bridge never enumerates chats. Requesting an
action the role does not permit returns:
{
"id": "abc123",
"ok": false,
"error": "action not permitted on this bridge",
"bridge_role": "host",
"allowed_actions": ["chat_history", "contacts_lookup", "response_stats", "review", "search", "send", "send_preview", "status"]
}
allowed_actions in every error response, including unknown action, is the
list for the current role, not the worker's full action table.
Architecture
Local MCP server FDA helper on this Mac
---------------- ----------------------
Writes request-<id>.json --> launchd watches control/requests/ -->
Reads response-<id>.json <-- chatgpt-codex-imessage-helper (wrapper) -->
helper.py (FDA-granted, reads
chat.db + AddressBook, drives osascript)
The MCP server runs as an unprivileged local process without Full Disk Access. A LaunchAgent bridges it to the FDA-bearing helper:
- The AI host writes a JSON request file to
<bridge-folder>/control/requests/request-<id>.json - launchd fires the helper within ~1 second (WatchPaths with 1s ThrottleInterval)
- The helper reads the request, processes it, and writes a response to
<bridge-folder>/control/responses/response-<id>.json - The AI host polls for and reads the response (typically 2–5s total)
- The AI host deletes the response immediately after parsing it
Bridge Folder Layout
Both standard and hardened installations separate the source/code root from the
stable user-owned runtime bridge. Hardened mode additionally copies bin/ into
a root-owned code root:
<code-root>/
└── bin/
├── chatgpt-codex-imessage-helper # Compiled C wrapper (FDA target)
├── chatgpt-codex-imessage-confirm # Native, fail-safe send confirmation
├── helper.py # Python worker
└── send_gate.py # Send nonce validation
<bridge-folder>/
├── control/
│ ├── requests/ # AI host writes here
│ ├── responses/ # Helper writes here
│ └── log.txt # Helper diagnostics
├── contacts/
│ ├── blocked_chats.txt # User-maintained blocklist
│ ├── allowed_chats.txt # Standard-mode optional allowlist
│ └── read_policy.txt # blocklist or allowlist
└── nonces/ # Short-lived send-preview nonces
For a standard install, <code-root> is the source clone and <bridge-folder>
defaults to ~/Library/Application Support/ChatGPTCodexIMessage. Hardened
allowlist entries live separately in a root-owned config directory.
Request Format
All requests are JSON files with this structure:
{
"id": "<unique-request-id>",
"action": "<action-name>",
"params": { /* action-specific parameters */ }
}
Use a UUID or timestamp for id. The id must be unique within the bridge folder's lifetime.
The top-level JSON value and params must be objects, and action must be a
string. Request files are limited to 64 KiB. Invalid requests receive an error
response and do not prevent later queued requests from being processed.
Response Format
All responses are JSON files with this structure:
{
"id": "<same-as-request-id>",
"ok": true,
"action": "<action-name>",
/* action-specific response fields */
}
On error:
{
"id": "<same-as-request-id>",
"ok": false,
"error": "Error message"
}
Actions
status — Compatibility and installation checks
status does not read chat.db or return message content.
{"id": "abc123", "action": "status", "params": {}}
The response includes helper_version, protocol_version, bridge_role,
allowed_actions (sorted, for this role), code_root, bridge_root,
python_version, policy metadata, and local boolean checks for the database,
request directories, and native confirmation helper.
review — Triage recent messages
Request:
{
"id": "abc123",
"action": "review",
"params": {
"days": 2
}
}
Response:
{
"id": "abc123",
"ok": true,
"action": "review",
"days": 2,
"counts": {
"needs_reply": 3,
"low_priority": 5,
"skip": 12,
"total_messages": 156
},
"needs_reply": [
{
"chat_id": "+14155551234",
"label": "Angel Vossough",
"contact_name": "Angel Vossough",
"display_name": "",
"last_ts": "2026-08-10T14:32:15",
"last_text": "Are we still on for Thursday?",
"context": [
{
"ts": "2026-08-10T14:32:15",
"me": false,
"text": "Are we still on for Thursday?"
}
],
"msg_count": 1
}
],
"low_priority": [ /* same structure as needs_reply */ ],
"skip_summary": [
{
"chat_id": "chat123456789",
"label": "Uber",
"last_ts": "2026-08-10T09:15:00"
}
]
}
Sorts threads into three buckets: needs_reply (actionable, full text included), low_priority (can wait, full text included), and skip_summary (summary only, no text — typically automated messages).
search — Find messages by substring
Request:
{
"id": "abc123",
"action": "search",
"params": {
"term": "dinner plans",
"days": 30,
"limit": 100
}
}
Response:
{
"id": "abc123",
"ok": true,
"action": "search",
"term": "dinner plans",
"days": 30,
"match_count": 3,
"matches": [
{
"chat_id": "+14155551234",
"contact_name": "Alice",
"ts": "2026-08-05T18:22:00",
"is_from_me": false,
"text": "Let's finalize dinner plans for Friday"
}
]
}
Case-insensitive substring search across all threads. Results are sorted by date (newest first).
chat_history — Recent messages in one thread
Request:
{
"id": "abc123",
"action": "chat_history",
"params": {
"chat": "Angel Vossough",
"days": 14,
"limit": 100
}
}
chat accepts:
- Contact name (resolved via Contacts.app)
- Phone number (any format; last 10 digits are matched)
- Email address
- Note: Group chat IDs (e.g.,
chat123456789) are NOT supported for sending. They work for read-only actions likechat_historyandreview.
Response:
{
"id": "abc123",
"ok": true,
"action": "chat_history",
"chat_query": "Angel Vossough",
"resolved_substr": "5551234",
"count": 2,
"messages": [
{
"chat_id": "+14155551234",
"contact_name": "Angel Vossough",
"ts": "2026-08-10T14:32:15",
"is_from_me": false,
"text": "Are we still on for Thursday?"
},
{
"chat_id": "+14155551234",
"contact_name": "",
"ts": "2026-08-10T14:35:00",
"is_from_me": true,
"text": "Yes! See you at 3pm"
}
]
}
response_stats — Average reply time to one contact
Request:
{
"id": "abc123",
"action": "response_stats",
"params": {
"chat": "Angel Vossough",
"hours": 24
}
}
Response:
{
"id": "abc123",
"ok": true,
"action": "response_stats",
"chat_query": "Angel Vossough",
"resolved_substr": "5551234",
"hours": 24,
"sample_size": 15,
"avg_seconds": 1098,
"avg_human": "18.3m",
"median_seconds": 480,
"min_seconds": 12,
"max_seconds": 7200,
"total_inbound_messages": 23,
"total_outbound_messages": 19
}
Computes reply-time statistics over the specified window.
contacts_lookup — Find matching contacts
Request:
{
"id": "abc123",
"action": "contacts_lookup",
"params": {
"name": "Angel"
}
}
Response:
{
"id": "abc123",
"ok": true,
"action": "contacts_lookup",
"query": "Angel",
"match_count": 1,
"matches": [
{
"name": "Angel Vossough",
"phone_last10": "4155551234"
}
]
}
Searches Contacts.app by name. Returns up to 25 matches. Each match contains either phone_last10 (last 10 digits of phone number) or email (email address), depending on the contact's identifier. Useful for disambiguating before chat_history or send.
list_chats — Enumerate threads without content (manager only)
Available only on a manager bridge (see Bridge Roles). Lists the chats that
had activity inside a window so a management tool can show the user which
threads exist and let them build an allowlist or blocklist. The read policy is
deliberately not applied — the point is to see the threads a policy will be
written about — and the action never selects message.text or
message.attributedBody. A fixture database with sentinel bodies asserts that
no body reaches the response.
Request:
{
"id": "abc123",
"action": "list_chats",
"params": {
"days": 365,
"limit": 200,
"include_groups": true,
"query": "family"
}
}
| Param | Type | Default | Bounds |
|---|---|---|---|
days | number | 365 | (0, 3650] — its own bound, distinct from the 90-day cap on review/search/chat_history |
limit | integer | 200 | (0, 500] |
include_groups | boolean | true | must be a JSON boolean |
query | string | none | ≤ 100 chars; case-insensitive substring match on label, display_name, chat_id, and participant handles; blank means no filter |
Response:
{
"id": "abc123",
"ok": true,
"action": "list_chats",
"window_days": 365,
"chat_count": 2,
"truncated": false,
"chats": [
{
"chat_id": "chat100200300",
"kind": "group",
"display_name": "Family",
"label": "Family",
"participants": ["+14155551234", "+14155559876", "bob@example.com"],
"participant_count": 3,
"service": "iMessage",
"message_count": 41,
"last_activity_date": "2026-08-14"
},
{
"chat_id": "+14155551234",
"kind": "direct",
"display_name": "",
"label": "Alice Example",
"participants": ["+14155551234"],
"participant_count": 1,
"service": "iMessage",
"message_count": 12,
"last_activity_date": "2026-08-13"
}
]
}
- Ordered by most recent activity first.
truncatedistruewhen more chats matched thanlimit. kindcomes fromchat.style(43group,45direct); thechat…identifier heuristic is the fallback.labelis the group name, or a participant-derived label for unnamed groups, or the contact name for a direct chat; it falls back tochat_id.participantsholds at most 10 handles;participant_countis the full count.last_activity_dateis an ISO day, not a timestamp.
send_preview — Dry-run a send (validation only)
Request:
{
"id": "abc123",
"action": "send_preview",
"params": {
"to": "+14155551234",
"text": "Confirmed for 3pm.",
"service": "iMessage"
}
}
service can be "iMessage", "SMS", or omitted (defaults to iMessage).
Response:
{
"id": "abc123",
"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
}
Critical: The helper returns a send_nonce that must be echoed back in the subsequent send request. The nonce is bound to the exact (to, text, service) triple and expires after send_nonce_ttl_seconds (default 60s). This enforces the preview-then-confirm gate at the helper level.
send_preview does not read chat.db and does not send anything. It only validates the recipient and body, resolves the contact name, and checks the blocklist.
send — Actually send the message
Request:
{
"id": "abc123",
"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. If any of these differ, the helper rejects the request with a "send payload differs from preview" error.
Native macOS confirmation dialog. After nonce validation succeeds, the helper displays the resolved name, exact recipient address, service, and full message text in a scrollable read-only view. Cancel is the keyboard default. The user must deliberately select Send; cancelling or waiting 60 seconds aborts the send.
Response on success:
{
"id": "abc123",
"ok": true,
"action": "send",
"sent": {
"to": "+14155551234",
"resolved_name": "Alex",
"service": "iMessage",
"text_length": 18,
"sent_at": "2026-08-12T00:15:42"
}
}
Response on error:
{
"id": "abc123",
"ok": false,
"error": "send gate: missing nonce; call send_preview first"
}
Or:
{
"id": "abc123",
"ok": false,
"error": "send cancelled by user or timed out (60s dialog limit)"
}
The helper writes text to a temporary UTF-8 file, shells out to /usr/bin/osascript with a short AppleScript, and deletes the tempfile (even on failure).
Send-gate validation (enforced helper-side):
- The
send_noncemust be present, fresh (within TTL), and match the payload. - Nonces are single-use: consumed on first
sendattempt, deleted on any validation failure. - Replaying a used nonce, sending without a nonce, or changing the payload after preview all result in rejection before the confirmation dialog appears.
- After nonce validation, the user must approve via the native macOS dialog.
- Text must be 1–4000 chars with no C0 control bytes other than
\n,\r,\t. - Recipient must not be on
contacts/blocked_chats.txt. - Group chat IDs are NOT supported as send targets. Attempting to send to a
chatNNNNNidentifier will fail. Use individual phone numbers or email addresses only.
Redaction
Before returning any response that includes message text, the helper runs a regex-based redactor that masks:
- 2FA / verification codes (near keywords like
code,verification,OTP,passcode) - Credit-card-like digit runs (13–19 digits)
- US SSN patterns (
NNN-NN-NNNN)
Redacted content is replaced with [REDACTED-2FA], [REDACTED-CARD], or [REDACTED-SSN] depending on the pattern matched.
Known gaps:
- Dot-separated credit cards (
4111.1111.1111.1111) - PIN-labelled codes (
Your PIN is 4829) - Slash-separated SSNs (
123/45/6789) - Bare codes with no keyword (
839201 to confirm) - API keys (Stripe, GitHub, OpenAI tokens)
- Bank account / routing numbers
- Home addresses
- Dates of birth
The thread-level read policy (see below) is the reliable filter; redaction is a second line of defense.
Read Policy
contacts/blocked_chats.txt is checked before the redactor runs. Threads on the blocklist are dropped entirely—their text never enters the response JSON.
Format: one entry per line. Lines starting with # are ignored.
Matches:
- Phone numbers: last 10 digits compared (e.g.,
+1-555-123-4567,5551234567,(555) 123-4567all match) - Email addresses: full case-insensitive match
- Group chat IDs: anything starting with
chator containing a distinctive substring
Example:
# Therapist
+15551234567
# Attorney
lawyer@example.com
# Family group chat
chat123456789
Blocked threads are enforced for both inbound (read actions) and outbound (send actions).
When policy mode is allowlist, reads and contact lookup return only handles in
the allowlist; the blocklist still takes precedence. Hardened installs enforce
this mode with a root-owned list managed by configure_allowlist.py.
Typical Request Flow (Pseudocode)
import json, uuid, time, pathlib, os
bridge = pathlib.Path("<bridge-folder>")
req_id = uuid.uuid4().hex[:12]
# CRITICAL: Write to temp file first, then rename atomically.
# Direct write to request-*.json can cause launchd to fire mid-write.
tmp = bridge / "control" / "requests" / f".request-{req_id}.json.tmp"
final = bridge / "control" / "requests" / f"request-{req_id}.json"
tmp.write_text(
json.dumps({"id": req_id, "action": "review", "params": {"days": 2}})
)
# Atomic rename publishes the complete request.
os.rename(tmp, final)
# Poll for response
resp_path = bridge / "control" / "responses" / f"response-{req_id}.json"
for _ in range(30): # 15-second timeout
if resp_path.exists():
break
time.sleep(0.5)
# Read response
data = json.loads(resp_path.read_text())
resp_path.unlink(missing_ok=True)
if data["ok"]:
print(data)
else:
print(f"Error: {data['error']}")
Why atomic writes matter: The helper watches control/requests/ via launchd WatchPaths. If you write directly to request-*.json, launchd can fire before the write completes, causing JSON parse errors. Always use temp file + rename.
Sending Flow (Preview → Confirm → Send)
Recommended workflow:
- Resolve the recipient. If the user provided a name, call
contacts_lookupfirst. If multiple matches, surface them and ask. Note: Group chat IDs (likechat123456789) will be rejected at the preview stage—only individual phone numbers or email addresses work for sending. - Issue
send_preview. Show the user:- Resolved recipient name
- Service (iMessage / SMS)
- Full text and
text_length - Whether
blocked: true(if so, stop—don't prompt for approval) - If preview fails with "group chat IDs not supported", the recipient is a group—use an individual contact instead.
- Wait for explicit user approval in the AI chat. Do not proceed without confirmation.
- Issue
sendwith thesend_noncefrom step 2. Theto,text, andservicemust match the preview exactly. - The helper will display a native macOS dialog showing the recipient, service, and message preview. The user must click Send in this system dialog to complete the send (60-second timeout).
- If the AI chat approval takes >60s, re-run
send_previewto mint a fresh nonce. - Surface
sent.sent_atand resolved name as confirmation.
Permissions Required
Full Disk Access (FDA)
Required to read ~/Library/Messages/chat.db. The FDA grant is attached to the C wrapper's CDHash, not its path. Changing the source or compiler can produce a different CDHash, requiring re-grant.
Grant location:
System Settings → Privacy & Security → Full Disk Access
→ Add the exact wrapper path printed by the selected installer
Automation → Messages
Required to send. First send triggers a one-time prompt: "chatgpt-codex-imessage-helper wants to control Messages." Click OK.
Grant location:
System Settings → Privacy & Security → Automation
→ chatgpt-codex-imessage-helper → Messages (toggle on)
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Requests pile up in control/requests/, no responses | FDA not granted yet | Grant FDA to the wrapper binary (path printed by install.sh) |
sqlite3.OperationalError: unable to open database file in log.txt | FDA not granted, or grant stale | Re-add the wrapper in System Settings → Full Disk Access |
| First send fails with Automation prompt | macOS needs Automation permission | Click OK on the prompt; future sends will work |
send gate: missing nonce | send called without prior send_preview | Always call send_preview first and pass the returned nonce |
send payload differs from preview | Body or recipient changed after preview | Re-run send_preview to mint a fresh nonce for the new payload |
| Messages decode as empty | attributedBody parser failed | Check control/log.txt for a byte-count-only parser diagnostic |
What the Helper Won't Do
- No attachments, images, stickers, audio, Tapback reactions. Text fields only.
- No message editing or deletion. Once sent, a message is immutable from the helper's perspective.
- No message effects (balloons, confetti, invisible ink).
- No group-chat sending. Group chat IDs can be read (
review,chat_history), but cannot be used as send targets. Sending requires individual phone numbers or email addresses. - No group-chat creation. Cannot create new group chats.
- Only reads local
chat.db. If a thread hasn't synced to this Mac, it won't appear in search/review.
Files and Directories
| Path | Role |
|---|---|
control/requests/ | AI host writes request JSON here. Watched by launchd. |
control/responses/ | Helper writes mode-600 response JSON here. AI host reads and immediately deletes; helper reaps files older than one hour. |
control/log.txt | Internal helper diagnostics. Launchd stdout/stderr go to /dev/null so they cannot follow a user-controlled log symlink. |
contacts/blocked_chats.txt | User-maintained blocklist of sensitive chats. |
contacts/read_policy.txt | Standard-mode read policy selector. Hardened mode overrides it. |
contacts/allowed_chats.txt | Standard-mode optional allowlist. |
<code-root>/bin/chatgpt-codex-imessage-helper | Locally signed C wrapper (the FDA target). |
<code-root>/bin/helper.py | Python worker (reads chat.db, resolves contacts, redacts, drives osascript). |
<code-root>/bin/send_gate.py | Nonce minting and validation for send-preview/send gate. |
nonces/ | Short-lived per-nonce files bound to previewed sends. TTL-reaped on every helper run. |
Security Notes
- The bridge folder should be mode
700(user-only access). - Bridge runtime directories must be real, current-user-owned directories with no group/world permissions. The helper rejects symlinks and unsafe modes; it does not chmod existing objects.
- Requests must be current-user-owned regular files no larger than 64 KiB.
- The helper runs with Full Disk Access—a bug or compromise becomes a full-user-file-read primitive.
- Nonces are single-use and expire after 60s. A process that can write to the bridge can request readable content; hardened mode bounds that content to its root-owned allowlist. It cannot silently send without native user approval.
- See
SECURITY.mdfor full threat-model details.
License
This protocol is part of the imessage-review project, licensed under MIT.