Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

messages-cli

Read-only, structured access to the local Apple Messages database on macOS. Returns normalized JSON, so scripts and AI agents can query your message history without touching the live database.

Single Python file. Standard library only. No network access of any kind.

Covers iMessage, SMS, and RCS — Apple stores them in one table, and all three come back from the same queries with a service field to filter on. (SMS and RCS reach the Mac only if Text Message Forwarding is enabled on your iPhone; without it you get iMessage only.)

What this reads. Your entire iMessage/SMS history is personal data. This tool reads it from ~/Library/Messages/chat.db and prints results to stdout. It never writes to that database, never opens attachment contents, and never makes a network call — but you are responsible for where you send the output. The read-only guarantees are enforced in code and covered by tests; see Read-only guarantees.

Why this exists

Most scripts that read chat.db do SELECT text FROM message — and quietly lose a growing share of messages.

Newer macOS releases increasingly leave message.text NULL and store the body only in message.attributedBody, a binary blob in the legacy NeXT typedstream format (NSArchiver, not NSKeyedArchiver). plistlib cannot read it. On the database this was developed against, the affected count grew from 6 messages in 2022 to 1,745 in 2026 alone — an accelerating curve.

This tool decodes attributedBody. The decoder is validated against every message carrying both text and attributedBody — a ground-truth set of 45,686 rows — at 100.000% exact match, and it recovers 99.9% of the rows where attributedBody is the only source.

Install

Requires macOS and Python 3.9+ (the system /usr/bin/python3 is fine). No dependencies.

git clone https://github.com/kweiner/messages-cli.git ~/Projects/messages-cli
chmod +x ~/Projects/messages-cli/messages_cli.py
ln -sf ~/Projects/messages-cli/messages_cli.py ~/.local/bin/messages-cli

Make sure ~/.local/bin is on your PATH.

Full Disk Access is required. macOS TCC will deny reads of chat.db otherwise, with Operation not permitted.

TCC attributes permission to the application at the root of the process tree — your terminal app, not python3. Grant it in System Settings → Privacy & Security → Full Disk Access, add your terminal (Terminal, iTerm, Ghostty, …), then quit and relaunch it; grants only apply at launch. For SSH use, the grant belongs to /usr/libexec/sshd-keygen-wrapper, though macOS often adds it automatically when you enable Remote Login.

If access is denied, messages-cli exits 3 and prints which permission is missing and where to grant it.

Upgrade: git pull — the symlink picks it up. Re-run the tests. Uninstall: rm ~/.local/bin/messages-cli && rm -rf ~/Projects/messages-cli. Nothing is written anywhere else.

Read-only guarantees

The Messages database is live, irreplaceable, and iCloud-synced; a stray write could propagate damage to every device on the account. Safeguards are in code, not just convention:

  1. Every connection opens with the SQLite URI flag mode=ro.
  2. assert_read_only() runs a write probe at connect time and aborts the process if the write is accepted. A proven-read-only handle is required before any query runs.
  3. No CREATE/INSERT/UPDATE/DELETE/VACUUM/ATTACH/write-PRAGMA statement appears anywhere in the source.
  4. No caches, temp files, or journals are written beside chat.db.
  5. Attachment files are stat()-ed for existence, never opened.

Independently, Apple's core tables carry triggers calling SQLite functions registered only inside Messages.app, so writes from an outside process fail at statement-preparation time regardless. Two independent barriers.

Verify at any time:

python3 test_readonly.py

The suite asserts that CREATE/INSERT/UPDATE/DELETE/DROP/VACUUM are rejected, that row counts are unchanged after the probes, that chat.db does not change size across a full command sweep, that no artifacts appear in ~/Library/Messages, and that the abort path fires on a deliberately writable handle. It also re-validates the decoder against every dual-source row.

Privacy model

  • Message bodies go to stdout only, for the query you asked for.
  • Errors go to stderr and carry the command name, a database error, IDs and counts — never message text.
  • --no-text returns full metadata with bodies replaced by text_length.
  • Contact names are resolved locally; the raw handle is always preserved.
  • Default limits keep an accidental query from dumping the whole archive.
  • Attachment metadata only: no image decoding, audio transcription, or OCR.

Usage

Global flags come before the subcommand:

--db PATH · --format json|text (default json) · --no-contacts · --no-text · --version

Command Purpose
info database path, counts, date range, read-only status
list-chats conversations, with participants and message counts
list-handles phone numbers / emails, folded across services
search QUERY substring search over message text
thread messages for a --handle or --chat-id
chat messages in one --chat-id
context messages surrounding a --message-id
export paginated dump of a chat or handle, with a total count

Filters: --handle --chat-id --since --until --limit --offset --order asc|desc --case-sensitive --include-tapbacks --include-system --group-only --one-to-one-only --min-messages --before --after

Dates accept YYYY-MM-DD or ISO 8601. --since is inclusive from 00:00:00; --until inclusive through 23:59:59.

Default limit is 200 (50 for search, 1000 for export); hard cap 5000. Tapbacks and system messages are excluded unless explicitly included.

messages-cli info
messages-cli --format text list-chats --limit 20
messages-cli list-chats --group-only --limit 10
messages-cli list-handles --min-messages 50
messages-cli search "invoice" --limit 50
messages-cli search "dinner" --handle "+15551234567" --since 2026-01-01
messages-cli thread --handle "+15551234567" --since 2026-01-01
messages-cli chat --chat-id 131 --limit 100
messages-cli context --message-id 52520 --before 10 --after 10
messages-cli export --chat-id 131 --since 2026-01-01 --limit 1000
messages-cli --no-text search "invoice"     # metadata only, no bodies

Contact names

Resolution is on by default, read directly from the local AddressBook SQLite files. This needs no Contacts permission beyond the Full Disk Access chat.db already requires, and costs about 23 ms.

It is a soft dependency: an unreadable AddressBook or an unmatched handle yields display_name: null, and the raw handle is always preserved. Pass --no-contacts for raw handles only.

Exit codes

0 success · 2 usage/bad argument · 3 database missing or access denied (Full Disk Access) · 4 database error · 5 not found

Success writes JSON to stdout and nothing to stderr. Failure writes a JSON error object to stderr and nothing to stdout. No decorative text either way.

JSON schema

Message

{
  "message_id": 52892,
  "guid": "50C0B2E7-...",
  "chat_id": 896,
  "timestamp": "2026-09-02T14:00:01.257667-07:00",
  "is_from_me": false,
  "sender": { "handle": "+15551234567", "display_name": "Alex Rivera" },
  "text": "message text",
  "service": "iMessage",
  "kind": "message",
  "attachments": [],
  "text_source": "text"
}

kind is one of message, tapback, participant_change, group_name_change, system. text_source is text or attributedBody. sender.handle is null and display_name is "me" for your own messages.

Optional keys appear only when relevant: subject, tapback ({action, kind}), reacts_to_guid, reply_to_guid, edited_at, retracted_at, group_title, is_audio_message. With --no-text: text is null plus text_redacted: true and text_length.

Attachment (metadata only; contents never read)

{ "filename": "IMG_0042.HEIC", "mime_type": "image/heic",
  "uti": "public.heic", "path": "/Users/you/Library/Messages/Attachments/...",
  "size_bytes": 1048576, "is_sticker": false, "is_missing": false }

Chat

{ "chat_id": 131, "guid": "any;+;chat123456789", "is_group": true,
  "display_name": null, "chat_identifier": "chat123456789",
  "service": "iMessage", "is_archived": false,
  "participants": [{ "handle": "+15551234567", "display_name": "Alex Rivera" }],
  "participant_count": 2, "message_count": 608,
  "first_message_at": "...", "last_message_at": "..." }

is_group comes from chat.style (43 = group, 45 = one-to-one). chat_handle_join excludes you, so a 2-participant group chat is 3 people.

Handle

{ "handle": "+15551234567", "display_name": "Alex Rivera",
  "aliases": ["+15551234567", "5551234567"], "services": ["SMS", "iMessage"],
  "message_count": 1541, "last_message_at": "..." }

Export / context envelope

{ "total_matching": 542, "returned": 5, "offset": 0,
  "next_offset": 5, "truncated": true, "messages": [] }

context returns {anchor_message_id, chat_id, count, messages[]} with is_anchor: true on the anchor.

Agent usage

Designed to be driven by an AI agent, locally or over SSH. stdout is always JSON; stderr is always a JSON error object.

The standard three-step retrieval pattern:

# 1. Find candidates. Note the message_id values.
messages-cli search "project name" --handle "+15551234567" --limit 50

# 2. Expand around a promising hit for conversational context.
messages-cli context --message-id 52520 --before 15 --after 15

# 3. Pull the surrounding thread when you need the wider arc.
messages-cli thread --handle "+15551234567" --since 2026-01-01 --limit 500

Discovery when the handle is unknown: list-handles --min-messages 20 → find the person → list-chatssearch. list-handles folds phone-number formats and services together and reports aliases, so any format reaches the same person.

Rules for agents

  • message_id is the stable key; pass it to context. It is a database ROWID — valid on that Mac indefinitely, but not a global identifier.
  • Always bound queries with --since and --limit. Never try to dump the archive.
  • Paginate with --offset. export returns total_matching, truncated and next_offset — follow next_offset until it is null.
  • Check the exit code. 3 means Full Disk Access is missing and a human must fix it; retrying will not help.
  • Use --no-text to count and locate before pulling bodies — it keeps private content out of your context window.
  • Prefer display_name as the entity key when storing results, falling back to handle when it is null.
  • Tapbacks and system messages are hidden by default; add --include-tapbacks only when reactions matter.
  • text can be null on an attachment-only message. Check attachments before concluding a message was empty.

Over SSH

ssh your-mac '/Users/you/.local/bin/messages-cli search "term" --since 2026-01-01'

Use the absolute path. A non-interactive SSH session gets PATH=/usr/bin:/bin:/usr/sbin:/sbin, which does not include ~/.local/bin.

Exit codes propagate correctly, stdout stays clean JSON, and stderr carries only the JSON error object. Verified against the system Python 3.9.6 that SSH sessions get.

If you expose this over SSH, use key-only authentication. On macOS, PasswordAuthentication no alone is not enough — the system ships UsePAM yes, and PAM's keyboard-interactive path keeps prompting for a password. You need both:

PasswordAuthentication no
KbdInteractiveAuthentication no

Confirm with ssh -o PubkeyAuthentication=no user@host true, which should give Permission denied (publickey). — not a password prompt.

Known limitations

  • Messages not in any chat. Some rows have no chat_message_join entry (old or partially-deleted conversations). They surface in search with chat_id: null, but are unreachable via chat or list-chats.
  • Text recovery is 99.9%, not 100%. A small number of attributedBody-only rows do not decode. The decoder is exact on all dual-source rows.
  • Identity is per-handle, not per-person. Handles fold across services and formats, but someone using both a phone number and an email appears as two handles. Apple's person_centric_id was empty on every database tested.
  • Contacts is best-effort. Roughly three-quarters of active handles resolve; the rest are mostly shortcodes and businesses with no Contacts entry.
  • Search is substring only — no fuzzy, stemming, or semantic matching, by design.
  • Deleted messages are not recoverable. Recently-deleted items live in chat_recoverable_message_join and are not exposed.
  • Group renames appear as group_name_change messages with --include-system; only the current display_name is on the chat record.
  • --case-sensitive uses GLOB, which is ASCII-only for case folding.
  • Encrypted/expired "view once" media and satellite messages are not special-cased.

Notes on the schema

Findings from chat.db on macOS 26, in case they save you the reverse engineering:

  • Timestamps are nanoseconds since 2001-01-01 UTC (the Core Data epoch). Older databases used seconds; the code tolerates both.
  • attributedBody is typedstream, prefix \x04\x0bstreamtyped. Strings are framed as 0x2b <length> <utf-8>, where length is one byte, or 0x81 + uint16le, or 0x82 + uint32le. Class names are length-prefixed without the 0x2b marker, so scanning for 0x2b finds string values only.
  • chat.style: 43 = group, 45 = one-to-one.
  • Tapbacks live in associated_message_type (2000–2006 added, 3000–3006 removed) and point at associated_message_guid, which is prefixed p:0/ or bp:.
  • item_type ≠ 0 marks system messages: 1 participant change, 2 group name change.
  • U+FFFC (object replacement character) marks where an attachment sits inline in the body text.
  • Handles duplicate across services; fold on the last 10 digits for phone numbers and lowercase for e-mail.

License

MIT — see LICENSE.

About

Read-only, structured access to the local Apple Messages database on macOS. Normalized JSON for scripts and AI agents.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages