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-only list_chats action, the bridge role table below, and the bridge_role / allowed_actions fields in status.
  • 1.1status action 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.

ActionhostmanagerReturns message bodies
statusyesyesno
review, search, chat_historyyesnoyes
response_statsyesnono
contacts_lookupyes (policy-filtered)yesno
send_preview, sendyesnodraft only
list_chatsnoyesno

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:

  1. The AI host writes a JSON request file to <bridge-folder>/control/requests/request-<id>.json
  2. launchd fires the helper within ~1 second (WatchPaths with 1s ThrottleInterval)
  3. The helper reads the request, processes it, and writes a response to <bridge-folder>/control/responses/response-<id>.json
  4. The AI host polls for and reads the response (typically 2–5s total)
  5. 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 like chat_history and review.

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"
  }
}
ParamTypeDefaultBounds
daysnumber365(0, 3650] — its own bound, distinct from the 90-day cap on review/search/chat_history
limitinteger200(0, 500]
include_groupsbooleantruemust be a JSON boolean
querystringnone≤ 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. truncated is true when more chats matched than limit.
  • kind comes from chat.style (43 group, 45 direct); the chat… identifier heuristic is the fallback.
  • label is the group name, or a participant-derived label for unnamed groups, or the contact name for a direct chat; it falls back to chat_id.
  • participants holds at most 10 handles; participant_count is the full count.
  • last_activity_date is 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_nonce must be present, fresh (within TTL), and match the payload.
  • Nonces are single-use: consumed on first send attempt, 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 chatNNNNN identifier 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-4567 all match)
  • Email addresses: full case-insensitive match
  • Group chat IDs: anything starting with chat or 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:

  1. Resolve the recipient. If the user provided a name, call contacts_lookup first. If multiple matches, surface them and ask. Note: Group chat IDs (like chat123456789) will be rejected at the preview stage—only individual phone numbers or email addresses work for sending.
  2. 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.
  3. Wait for explicit user approval in the AI chat. Do not proceed without confirmation.
  4. Issue send with the send_nonce from step 2. The to, text, and service must match the preview exactly.
  5. 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).
  6. If the AI chat approval takes >60s, re-run send_preview to mint a fresh nonce.
  7. Surface sent.sent_at and 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

SymptomCauseFix
Requests pile up in control/requests/, no responsesFDA not granted yetGrant FDA to the wrapper binary (path printed by install.sh)
sqlite3.OperationalError: unable to open database file in log.txtFDA not granted, or grant staleRe-add the wrapper in System Settings → Full Disk Access
First send fails with Automation promptmacOS needs Automation permissionClick OK on the prompt; future sends will work
send gate: missing noncesend called without prior send_previewAlways call send_preview first and pass the returned nonce
send payload differs from previewBody or recipient changed after previewRe-run send_preview to mint a fresh nonce for the new payload
Messages decode as emptyattributedBody parser failedCheck 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

PathRole
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.txtInternal helper diagnostics. Launchd stdout/stderr go to /dev/null so they cannot follow a user-controlled log symlink.
contacts/blocked_chats.txtUser-maintained blocklist of sensitive chats.
contacts/read_policy.txtStandard-mode read policy selector. Hardened mode overrides it.
contacts/allowed_chats.txtStandard-mode optional allowlist.
<code-root>/bin/chatgpt-codex-imessage-helperLocally signed C wrapper (the FDA target).
<code-root>/bin/helper.pyPython worker (reads chat.db, resolves contacts, redacts, drives osascript).
<code-root>/bin/send_gate.pyNonce 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.md for full threat-model details.

License

This protocol is part of the imessage-review project, licensed under MIT.