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.dband 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.
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.
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-cliMake 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.
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:
- Every connection opens with the SQLite URI flag
mode=ro. 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.- No
CREATE/INSERT/UPDATE/DELETE/VACUUM/ATTACH/write-PRAGMAstatement appears anywhere in the source. - No caches, temp files, or journals are written beside
chat.db. - 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.pyThe 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.
- 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-textreturns full metadata with bodies replaced bytext_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.
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 bodiesResolution 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.
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.
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.
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 500Discovery when the handle is unknown: list-handles --min-messages 20 →
find the person → list-chats → search. list-handles folds phone-number
formats and services together and reports aliases, so any format reaches the
same person.
Rules for agents
message_idis the stable key; pass it tocontext. It is a database ROWID — valid on that Mac indefinitely, but not a global identifier.- Always bound queries with
--sinceand--limit. Never try to dump the archive. - Paginate with
--offset.exportreturnstotal_matching,truncatedandnext_offset— follownext_offsetuntil it isnull. - Check the exit code.
3means Full Disk Access is missing and a human must fix it; retrying will not help. - Use
--no-textto count and locate before pulling bodies — it keeps private content out of your context window. - Prefer
display_nameas the entity key when storing results, falling back tohandlewhen it is null. - Tapbacks and system messages are hidden by default; add
--include-tapbacksonly when reactions matter. textcan benullon an attachment-only message. Checkattachmentsbefore concluding a message was empty.
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.
- Messages not in any chat. Some rows have no
chat_message_joinentry (old or partially-deleted conversations). They surface insearchwithchat_id: null, but are unreachable viachatorlist-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_idwas 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_joinand are not exposed. - Group renames appear as
group_name_changemessages with--include-system; only the currentdisplay_nameis on the chat record. --case-sensitiveuses GLOB, which is ASCII-only for case folding.- Encrypted/expired "view once" media and satellite messages are not special-cased.
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. attributedBodyistypedstream, prefix\x04\x0bstreamtyped. Strings are framed as0x2b <length> <utf-8>, where length is one byte, or0x81+ uint16le, or0x82+ uint32le. Class names are length-prefixed without the0x2bmarker, so scanning for0x2bfinds 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 atassociated_message_guid, which is prefixedp:0/orbp:. 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.
MIT — see LICENSE.