Read and send iMessages from the command line.
msg talks straight to the Messages database on your Mac. There is no server to
run, no REST API to authenticate against, and no third-party service that ever
sees your messages. Nothing leaves the machine.
$ msg chats
1 Priya Raman 9m ago direct
75 Dana Reyes 32m ago direct
6 Thursday Climbing 1h ago 2 people
23 Dana Reyes, Sam Oyelaran 7h ago 3 people
$ msg chat "Dana" -n 3
Dana Reyes
5:30 PM Dana Reyes: are you around later
5:31 PM me: after 6, yeah
9:46 PM Dana Reyes: works, see you then
- macOS, with Messages signed in
- Full Disk Access, held by the daemon or by your terminal
msg is two self-contained binaries and needs no runtime installed. Building it
needs Rust and the Xcode command line tools, for codesign.
The Messages database is protected by TCC, so something has to hold Full Disk Access. The daemon is the better place for it, because a grant on your terminal covers Mail, Safari history, Photos, and every file you can read, and every command you run there inherits it.
To grant it to the terminal instead, add your terminal application under System Settings > Privacy & Security > Full Disk Access and restart it. macOS only applies the permission to processes started after the change, so an already-open terminal keeps failing until it is relaunched.
Without either, every command exits with status 2 and an explanation.
git clone git@github.com:ninjudd/msg.git
cd msg
./scripts/build.sh # both binaries, and the signed daemon
mkdir -p ~/.local/bin # macOS does not create this
ln -s "$PWD/build/msg" ~/.local/bin/msgmacOS puts neither ~/.local/bin on your PATH nor the directory itself on
disk, so if msg is not found afterwards:
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
exec zshAnywhere already on your PATH works just as well; ~/.local/bin is only a
convention that needs no sudo.
build/msg is the command and build/msgd.app is the daemon. msg daemon install looks for msgd.app beside the real binary, following the symlink
first, so a link into ~/.local/bin is fine — but a copy of msg needs
msgd.app copied alongside it, or --from pointing at one.
To run without installing, cargo run --bin msg -- <command> works from the
checkout.
msg chats # by most recent activity, one row per person
msg chats dana # filter by contact name, handle, or identifier
msg chats -n 100 # more of them
msg chats --unknown # include the ones Messages filters awaySomeone you reach at two addresses is one row, not two — the same merge
reading describes, so the count here agrees with what msg chat
prints. -n counts conversations, so it means the same thing on both.
Conversations that Messages filters as unknown senders are hidden, matching what your phone shows. On one real database that removed 1,395 of 2,559 conversations: verification codes, delivery notifications, and one-off numbers that were never saved as contacts.
A chat can be named by its rowid, by a handle, or by part of its name. A name
matches from the start of a word, so ana reaches Ana Duarte and not Dana
Reyes, while typing a prefix still works and rob still reaches Robin. An
address is different and matches anywhere, because the middle of a phone number
is exactly how anyone types a fragment of one. When a name still matches more
than one conversation, msg lists the candidates instead of guessing.
A nickname counts as one of those names, so someone filed under their full name and known to you as something else is found by either — and is shown as the nickname, since that is what you call them.
Several conversations with one person are not an ambiguity. Messages keeps a
conversation per address, so someone you reach at a phone number and an email
address has two of them, and naming that person matches both. They are one
Contacts record, so msg answers with whichever was last active rather than
asking a question that has no answer. A fragment counts as naming them: typing
fewer letters does not make it two people.
Several names are a room. Each argument names a person, so msg chat dana sam is the conversation whose members are exactly Dana and Sam and you. Exactly
those: a room that merely contains them is not the answer, because a command
that quietly hands back a conversation with somebody in it you did not name is
worse than one that says there is no such conversation. A room with a name of
its own is still reached by that name.
Because each argument is a person, a name with a space in it has to be
quoted — msg chat "Ana Duarte" is one person and msg chat ana duarte is
two.
A name is a person before it is anything else. Someone's own conversation wins over every group they are in, because being in a room is not being the person — which is what makes a first name usable, since a first name reaches every room its owner is a member of. A group is still reached by its own name, or by its rowid, and somebody you only ever share a room with still finds the room. A group named after somebody you also have a conversation with is the one case where both claims are equally good, so that is reported as an ambiguity rather than decided for you.
What still reports an ambiguity is a question that has an answer. Two different people who happen to share a name are two people, and what gets collapsed is the Contacts record, never the name it renders as.
msg chats lists conversations and msg chat prints one, which is a letter
apart and worth reading twice. The plural is the index; the singular opens
something in it.
msg chat "Ship Room" # a conversation
msg chat dana # one person: their own conversation
msg chat dana sam # two people: the room with exactly those two
msg chat 42 -n 200 # by rowid, with more history
msg chat dana --since 7d # only the last week
msg chat dana --since 2026-01-15
msg chat dana --who # name who reacted: ← ❤️ sam, 🙏 me
msg chat dana --tapbacks # reactions as their own rows, not trailing--since accepts a duration (30m, 2h, 7d, 4w), an ISO date like
2026-01-15, or a full timestamp. A bare date means midnight UTC.
A transcript carries a day line whenever the day changes — Today,
Yesterday, a bare weekday inside the last week, then Friday, July 7, the
year only when it is not this year — bold when writing to a terminal,
honouring NO_COLOR and TERM=dumb. Each message shows only its time, the
same shape Messages uses. watch does the
same across its stream. search keeps its stamps as they always were — a date
on every line except today's, which shows only a time — since its results jump
between days by construction.
A name reads the person; a rowid reads the thread. Messages keeps a separate conversation for every address someone has, so a person you reach at a phone number and an email address has two — and naming them shows both as one transcript, in the order the messages arrived:
msg chat dana # everything Dana said, at any of her addresses
msg chat dana@example.com # the same conversation, since an address names her
msg chat 42 # only the conversation with rowid 42An address names the person too — what resolves is the Contacts record, however
you point at it — so it reads the same whole conversation. What the address
adds is aim: its own thread leads the answer, and the leading thread is where a
send goes, so writing to dana@example.com cannot drift to her phone because
the phone thread spoke last.
The rowid is the way out when that is not what you want. msg chats merges on
the same rule, so it lists one row per person rather than one per thread, and
that row carries their total message count and the rowid a send would go to —
the newest of their threads. The older ones are not in the listing; msg chat <name> --json names them in merged, which is where to look for the rowid of a
thread the listing no longer shows.
A conversation Messages files under Unknown Senders is left out of the merge
unless --unknown is passed, so filtered content never arrives inside a
conversation you think of as known. Groups never merge.
--json names the thread a reply would go to in chat, as it always has, and
lists any others in merged. A person with one conversation is unchanged in
both output modes.
An inline reply says what it is answering, so it stops reading as an unrelated remark that happens to come later:
5:12 PM Dana Reyes: what's a diffusion transformer?
5:46 PM Dana Reyes: Btw do you think we can find the inflator?
5:50 PM me: It is the underlying model that generates music
↳ replying to Dana Reyes: what's a diffusion transformer?
Chronology is untouched — the quote hangs below the reply rather than moving
it.
--json carries the same as a replyTo object with the answered message's
rowid, sender, and excerpt. A reply whose original has been deleted still prints
as an ordinary message.
Attachments read as what they are, in the place they occupy in the message:
5:31 PM Dana Reyes: [#48213 IMG_4821.HEIC, 3.2 MB]
5:32 PM Dana Reyes: from the trip [#48214 clip.mov, 41.8 MB]
Messages stores a photo as a single invisible character in the body and keeps
the file elsewhere, so without this a message that is only a photo prints as
nothing at all. --json carries the same as an attachments array.
The number is the attachment's id, and it is how you get the file:
msg save 48213 # into the current directory
msg save 48213 --to ~/Downloads
msg save 48213 --to ~/Downloads --force # replace one already thereThe file lives under ~/Library/Messages/Attachments, which your terminal has
no permission to read — so msg names it by id and never by path. The daemon
opens the file, hands over the bytes, and the CLI writes them where you asked,
with your permissions. That is the same shape sending uses, in reverse: the
daemon never accepts a path and never writes one, so it can be neither an
arbitrary-file reader nor an arbitrary-file writer.
Large attachments stream in chunks rather than arriving whole, so a 500MB video costs the same memory as a small photo. An interrupted save leaves nothing behind, and an existing file is refused rather than overwritten.
Messages sometimes keeps the row after deleting the file. msg save says so
plainly rather than writing an empty file.
msg search "dinner" # across every conversation
msg search "deploy" -c "Ship Room" # within one
msg search "dinner" --with dana # one person, wherever you talk
msg search "dinner" --from dana # only what they sent
msg search "invoice" --since 30d -n 50
msg search "dinner" -C 2 # two messages either side of each hit
msg search "dinner" -A 3 -B 1 # or each side separatelyA match on its own is a line torn out of a conversation, and it is often the
least informative line in it — sounds good, yeah ok. -A, -B and -C
put it back:
$ msg search "the inflator" -C 1
5:44 PM [Dana Reyes] me: heading to the lake tomorrow
> 5:46 PM [Dana Reyes] Dana Reyes: Btw do you think we can find the inflator?
5:50 PM [Dana Reyes] me: it is in the garage
--
2:11 PM [Ship Room] Sam Oyelaran: who packed the car
> 2:12 PM [Ship Room] Sam Oyelaran: no inflator either
> marks the hit and two spaces mark its context, so the distinction survives
being piped, pasted, or grepped again — this program writes no colour anywhere.
-- separates runs, as in grep. Windows that overlap or touch merge into
one run, so several hits in one exchange print once rather than repeating the
messages between them.
The window is a slice of the conversation, not a continuation of the search.
--from dana -A 2 means "what Dana said, plus what came next, whoever sent it";
filtering the window by the same person would return Dana's next two messages,
which is almost never the reply she got. --since bounds what counts as a hit
too, so a window on a hit near that boundary reaches back past it. Reactions
are not rows in a window — each trails its target, so a reaction to a message
in the window shows there, once.
-C is shorthand for both sides and loses to either given outright, including
zero — -C 2 -B 0 is two messages after each hit and none before, as in grep.
-n still counts hits, not printed lines, so -n 25 -C 5 is twenty-five
matches and however many messages that comes to. --json carries the same as
matched: false on context messages and a group number per run; a search
without context is byte-identical to what it was before these existed.
-C and -c differ only in case and mean entirely different things — -c
scopes to a conversation, -C sets the context width. -c 3 is a legitimate
way to name chat 3, so a case slip cannot be guarded against. The -A/-B/-C
muscle memory was judged worth it.
Search matches the message body wherever it lives. Most bodies are not in
message.text at all but archived into attributedBody, so matching has to look
inside that blob rather than cast it to text — a cast stops at the first NUL byte,
which in an archived body comes long before the words.
-c scopes to a conversation; --with and --from scope to a person, which
is not the same thing. Someone you message one to one and in three group chats
is four conversations and one contact, and Messages stores each of their
addresses — phone, email, a second number — as a separate handle. Both flags
gather all of them, so a search is of the person rather than of whichever
address happened to be used.
Naming any one of a person's addresses reaches all of them, because what is being resolved is the Contacts record rather than the address you typed. Two records can carry the same name, though, and those are two people: naming them reports the ambiguity, with an address alongside each, rather than answering with both people's messages under one name. Name an address to say which.
The two differ in whose messages come back. --from is only theirs. --with is
the exchange: theirs everywhere, plus your own where the conversation is just
the two of you — in a group your messages went to the room rather than to them,
so they are left out.
msg watch # print new messages as they arrive
msg watch -c dana # just one conversation
msg watch --json # JSON lines, one object per messageWith the daemon running, new messages arrive as they land. Without
it, watch polls every 3 seconds; change that with --interval.
A default watch shows messages only — a stream cannot revise a line it
already printed, so reactions stay off it entirely. To follow them live, pass
--tapbacks, which prints each one as its own line as it lands.
msg send dana "on my way"
msg send dana --file ~/diagram.png
msg send dana "hi" --dry-run # print what would be sent, send nothingSending is off until you switch it on, and it goes through the daemon. Two independent gates stand in front of it:
# ~/.config/msg/config.toml
send = trueand macOS has to allow msgd to control Messages, under System Settings >
Privacy & Security > Automation. The first is a switch you can find and read;
the second is enforced by the operating system, so it holds even when something
rewrites the config. The daemon checks the config key, not the CLI — a check a
program runs on itself is advice rather than a gate.
Automation is a separate permission from Full Disk Access, so the two can be set independently: a daemon allowed to send but refused the database, or the reverse. Sending by chat guid needs no database read at all.
msg daemon automation reports both gates and sends nothing to anyone. Either
one can be closed to take away the ability to send:
msg daemon automation --settings # the switch, under Automation
tccutil reset AppleEvents com.ninjudd.msgd--dry-run works whatever the gates say, so the disabled state stays
inspectable.
A confirmation names the address, not just the person.
$ msg send dana "on my way" --dry-run
would send to Dana Reyes (dana@example.com): on my way
Somebody you reach at a phone number and an email address has two conversations that display the same name, and a name picks the one last active. An address pins it: the typed address's own thread leads, so the send goes by that route whatever spoke last. The address is the only thing that distinguishes them, and the routes are not interchangeable — one of them may be an SMS fallback, and the most recent is not always the one that gets read. A dry run that printed only the name would be identical in both cases, which would make it useless as the check it is for. Conversations with a name of their own are named by it, since a room is not ambiguous the way a person is.
The chat identifier and the message body are passed to AppleScript as arguments
rather than interpolated into it, so quotes, backslashes, and newlines in a
message need no escaping and cannot alter the script. --file is read by the
CLI with your own permissions and handed to the daemon as bytes; the daemon
never opens a path a caller named, since it holds Full Disk Access and that
would make it read anything.
Handles are resolved to contact names automatically, in rendered output and in
--json alike. --no-names shows raw phone numbers and addresses, and skips
reading Contacts altogether.
msg contacts resolve answers "who is Dana, and how do I reach her" — for
you, or for any tool that needs an address rather than a transcript:
msg contacts resolve dana # the person, whole
msg contacts resolve dana --json # the same, as one JSON object
msg contacts resolve dana --emails # every email address, one per line
msg contacts resolve dana --email # exactly one, or an error naming them
msg contacts resolve dana --phones # every phone number
msg contacts resolve dana --phone # exactly oneResolution follows the same rules as naming a conversation: a name matches from the start of a word, an address given outright wins outright, a whole name settles the tie a fragment created, and two people are never guessed between. What differs is the domain — everyone in Contacts, whether or not you have ever messaged them — so a name that opens one conversation can still be ambiguous here, and the answer is to say which, by address.
The exit status says what happened, and stdout stays empty on every failure, so a command substitution fails empty rather than splicing the wrong address into another command:
| Status | Meaning |
|---|---|
| 0 | one person; the output is theirs |
| 1 | nobody matched, or the person has none of what a singular flag asked for |
| 2 | the Contacts databases could not be read, in whole or in part |
| 3 | not unique: several people, or several values under --email/--phone |
Candidates go to stderr, one per line with an address alongside each, which is what to disambiguate by:
$ msg contacts resolve dana --email
2 email addresses for Dana Reyes:
dana@example.com (home)
dana@work.example (work)
Composing with another tool is a two-liner that stops when resolution does:
email=$(msg contacts resolve dana --email) &&
gog gmail search "from:$email"The JSON is one object, and the schema only ever grows:
{
"id": "1ABC…:42",
"name": "Bob",
"filedAs": "Robert Chen",
"emails": [{ "value": "bob@example.com", "label": "home" }],
"phones": [{ "value": "+13105551234", "label": "mobile" }]
}id is opaque and not promised durable across Contacts resyncs. emails
and phones are always present, even empty. label is absent when Contacts
holds none; Apple's preset labels read as home, work, mobile, and a
label somebody typed themselves is kept as typed. Phone numbers come out
with their separators dropped — +13105551234, never (310) 555-1234 — and
a + is kept when stored but never invented, since a bare local number
carries nothing to derive the country from. On one real database 64% of
stored numbers carried a country code, so mixed shapes are a fact of the
data rather than a bug here
(contact-resolution.md §9).
Two edges, stated rather than smoothed over. A record with no phone and no
email never resolves — msg indexes Contacts by address, and someone with
no way to reach them answers no question this command asks. And a partial
Contacts read, one account's database unreadable, is status 2 even when a
healthy account held a match: "nobody matched" and "exactly one" are claims
about all of Contacts, and half the data cannot back either. Name rendering
elsewhere stays best-effort; an identity answer does not.
Records filed under one name are one person — the same unification Contacts
itself shows as a single card when your accounts each hold a copy of
somebody. A resolve answers with every card's addresses merged, and this
holds in conversation resolution too, so resolve and chat cannot
disagree about who somebody is. An address shared by records filed under
different names is refused, naming both, because a parent and a child on
one home line are two people, and picking between people is the thing this
command never does.
Every read command accepts --json. watch --json emits newline-delimited
JSON, one object per message, suitable for streaming into another process.
msg search "invoice" --json | jq '.[] | {date, sender, body}'skills/msg/SKILL.md is an
Agent Skill that teaches Claude Code, Codex, and
anything else speaking that format how to drive msg: which command answers
which question, how names resolve, that nothing is sent without an explicit
ask and a --dry-run first — and that sending stays off unless
the user twice confirms they want it on, since most people only ever read and
search. Install it like the binary, with a symlink, so it tracks the checkout:
mkdir -p ~/.claude/skills && ln -s "$PWD/skills/msg" ~/.claude/skills/msg # Claude Code
mkdir -p ~/.codex/skills && ln -s "$PWD/skills/msg" ~/.codex/skills/msg # CodexSkills are discovered at startup, so restart a session that was already open. The skill grants nothing: the agent's own permission prompts still stand in front of every command it runs, and sending stays behind the same two gates no matter who is typing.
msg can read the Messages database itself, but that means granting Full Disk
Access to your terminal, and that grant is not scoped to messages: it covers
Mail, Safari history, Photos, every application container, and every file you
can read. Everything you run in that terminal inherits it.
msgd moves the grant to one binary. It is a launchd agent that holds Full Disk
Access on its own, answers a fixed set of questions over a unix socket, and
takes no filesystem path from anyone. The CLI needs no permission at all.
./scripts/build.sh # compile msgd and sign it inside msgd.app
msg daemon install # copy it into place and start itThen switch on msgd under System Settings > Privacy & Security > Full Disk
Access, which the install opens for you the first time. It appears in that list
because the daemon has already tried to read and been refused — a denied access
is what creates the entry, and there is no command that can add one. Give it a
minute if it is not there yet.
Reinstalling a daemon that is already granted opens nothing: the install asks it whether it can read before deciding there is anything left for you to do. The grant is keyed to the bundle identifier and the signing certificate rather than to the build, so it survives a rebuild.
msg daemon status # installed? running? granted? how many watchers?
msg daemon automation # may it drive Messages? sends nothing
msg daemon uninstall # stop and remove itIt ships as an app bundle in ~/.local/libexec/msgd.app, which nothing ever
launches. The bundle exists so macOS keys its permissions by bundle identifier
rather than by executable path: a path-keyed permission cannot be switched off —
the toggle asks for Touch ID and then silently does nothing — which makes
granting Automation a one-way door. The reasoning and the measurements are in
daemon-and-permissions.md §13.
Being a bundle, it has an icon, which is how you find it in those two lists.
assets/msgd.svg is the source and assets/msgd.icns is what ships; both are
committed and the build only copies the .icns. ./scripts/build-icon.sh
regenerates it,
rasterizing with qlmanage so the pipeline needs nothing but macOS, and is
deliberately not part of the build.
msg talks to the daemon whenever one is listening and reads the database
directly when one is not, so nothing breaks if you never install it. --db
always reads locally and never reaches the daemon.
Signing. The grant is pinned to the daemon's code signature, so an ad-hoc
signature — matched by hash — dies on every rebuild and has to be granted again.
To avoid that, the first ./scripts/build.sh creates a self-signed msg dev
certificate in your login keychain and signs with it; the requirement then
anchors to the certificate and survives rebuilds. Nothing is submitted anywhere,
codesign is offline, and the certificate is never added to any trust store.
macOS asks before codesign uses that key, once per build. Answering "Always
Allow" removes the prompt and, with it, the thing that stops local code from
signing its own daemon and inheriting the grant — see
signing-identity.md.
MSG_SIGN_IDENTITY="my identity" ./scripts/build.sh # a different certificate
MSG_SIGN_IDENTITY=- ./scripts/build.sh # ad-hoc, no certificate
security delete-identity -c "msg dev" # remove the one msg createdWhat it changes. watch stops polling — the daemon tails the write-ahead log
and pushes to every watcher, so one process does the work no matter how many
terminals are following. Contact names are resolved by the daemon too, so
Contacts needs no permission of its own. And sending runs from the
daemon, which is what makes "may this tool text people?" an operating system
permission rather than a flag a program honours about itself.
The two permissions are independent. Granting Full Disk Access does not let
msgd send, granting Automation does not let it read, and each is a separate
switch in a separate list.
Uninstalling does not withdraw the grants. They outlive the bundle they were granted to. Both are switches in System Settings, and both can be revoked from a script, because they are keyed to the bundle identifier:
tccutil reset SystemPolicyAllFiles com.ninjudd.msgd # stop it reading
tccutil reset AppleEvents com.ninjudd.msgd # stop it sendingThe reasoning behind the design — including why the socket carries no
authentication, and why the daemon is a single executable rather than a copy of
node — is in
docs/projects/all/daemon-and-permissions.md.
| Option | Applies to | Meaning |
|---|---|---|
--db <path> |
all | read a different chat.db |
--no-names |
all | skip Contacts, show raw handles |
--unknown |
chats, search, watch |
include filtered unknown senders |
-n, --limit <count> |
chats, chat, search |
how many results |
--since <when> |
chat, search |
duration or date lower bound |
-c, --chat <chat> |
search, watch |
restrict to one conversation |
--tapbacks |
chat, watch |
reactions as their own rows instead of trailing their message |
--who |
chat, search |
name who reacted in the trail |
-A, --after <count> |
search |
messages to show after each hit |
-B, --before <count> |
search |
messages to show before each hit |
-C, --context <count> |
search |
both, and note the clash with -c |
--interval <seconds> |
watch |
poll frequency, without a daemon |
-f, --file <path> |
send |
send a file instead of text |
--dry-run |
send |
show without sending |
--emails, --phones |
contacts resolve |
every value of that kind, one per line |
--email, --phone |
contacts resolve |
exactly one value, or exit 3 naming the candidates |
--json |
all read commands | machine-readable output |
| Variable | Meaning |
|---|---|
MSG_DB |
path to an alternate chat.db, same as --db |
MSG_ADDRESSBOOK |
an alternate AddressBook directory, MSG_DB's idea for Contacts |
MSG_CONTACTS_SOURCE |
UUID of the Contacts source whose names win |
MSG_SOCKET |
where the daemon listens, default ~/.local/state/msg/msgd.sock |
MSG_STATE_DIR |
socket and log directory, default ~/.local/state/msg |
MSG_CONFIG |
config file, default ~/.config/msg/config.toml |
MSG_SIGN_IDENTITY |
Code Signing identity for ./scripts/build.sh |
MSG_DB steers the CLI, which reads that database itself rather than asking the
daemon — the same as --db, so a fixture stays a fixture even with a daemon
running. MSG_ADDRESSBOOK steers the same way, for the same reason: the daemon
answers contact questions from its own AddressBook, and a fixture that only
applied when no daemon was listening would be worse than none.
MSG_SOCKET, MSG_STATE_DIR, MSG_CONFIG and MSG_CONTACTS_SOURCE are read by
the daemon, and a launchd job inherits nothing from your shell. msg daemon install copies whichever of them are set into the agent and prints what it
carried; changing one afterwards means installing again.
MSG_DB and MSG_ADDRESSBOOK are never carried into the agent, deliberately.
Either would outlive the shell that set it, leaving a daemon pinned to a
fixture and a CLI with no way to know. To serve one, run msgd yourself with
the variable set.
The Messages schema has a number of sharp edges. Most of the code here exists to handle them.
Dates are nanoseconds since 2001-01-01, not Unix seconds. Current values sit
around 8.1e17. That is an ordinary i64 here, but it is past
Number.MAX_SAFE_INTEGER at 9.0e15, which cost the JavaScript build a layer of
BigInt arithmetic in every query and conversion. Dividing by a thousand at the
wrong moment is the failure mode, and the seconds/nanoseconds threshold is what
tells the two apart: rows written before the 2011 schema change are in seconds.
message.text is usually NULL. On a sample of 20,000 messages from a live
database, 97.6% carried their body only in attributedBody, an NSArchiver
typedstream blob left over from the days when messages were archived
NSAttributedString objects. src/apple.rs decodes that format by hand, with no
Objective-C bridge and no dependency on the deprecated NSUnarchiver. It decoded
every one of those 19,524 blobs.
Tapbacks are messages. A reaction is stored as an ordinary row with
associated_message_type != 0, so a naive query mixes Liked "see you then"
into the conversation. By default the rows are filtered out and each reaction
trails the message it reacted to after an arrow —
works, see you then ← ❤️ — with the raw type published beside the symbol in
--json. --tapbacks trades the trail for the rows, which keeps their
timestamps visible.
Filtering is a category, not a flag. chat.is_filtered is not a boolean.
It holds 0 for ordinary conversations and a nonzero category for the ones
Messages sets aside, so a = 1 test silently lets a whole category through. One
database here used 1 for 1,352 conversations and 2 for another 43, none of
which had a saved contact. msg treats any nonzero value as filtered.
A chat's name is often absent. display_name is set for named group chats
and empty for everything else, so direct messages fall back to participant
handles, and from there to contact names.
The database is read-only and may be locked. Messages.app holds it open in
WAL mode. msg opens it read-only, and if the write-ahead log cannot be opened
alongside it, copies the database and its sidecar files to a temporary location
and reads the copy.
Names come from the Contacts databases under
~/Library/Application Support/AddressBook, which msg reads directly. There
is one database per account (iCloud, local, Google, and so on), and all of them
are merged.
Numbers are stored in whatever shape they were typed. One real database held
ten distinct formats for the same kind of number, including +13105551234,
(310) 555-1234, 310.555.1234, 1-310-555-1234 and a bare 3105551234. Both
sides of a comparison are stripped to digits, and numbers long enough to carry a
country code are matched on their last ten digits. Short codes are matched
whole, and email handles are matched case-insensitively.
A nickname is what you call someone, so it is what they are called here. Someone filed as Robert Chen with a nickname of Bob reads as Bob — in the chat list, at the head of a conversation, and against every message he sent. You told Contacts what he is called; a transcript that says Robert Chen throughout is answering a question nobody asked.
Both names still find him. msg chat bob and msg chat "Robert Chen" open the
same conversation, and it reads as Bob either way — the display does not follow
whichever name you typed. The filed name is displaced, not discarded, which
matters because it is the one you have when a nickname is all you remember of
somebody and the one somebody else would search by.
msg contacts shows both, because identifying somebody is its whole job rather
than a label on something else — and it takes a name as readily as an address,
since a name is the ordinary way to ask who somebody is:
$ msg contacts bob
+13105551234 Bob (Robert Chen)
bob@example.com Bob (Robert Chen)
$ msg contacts +13105551234
+13105551234 Bob (Robert Chen)
A name matches either of somebody's two names, as a substring, so it can reach
more than one person and lists every address of each. An address given in full
answers on its own instead — otherwise naming somebody exactly could drag in
whoever else happens to contain those characters. A term matching nobody is
echoed back as (unknown).
That is the one place the pair appears. A transcript labelled Bob (Robert Chen) on every line would be unreadable, and a chat list of them worse.
--json keeps them apart, as name and filedAs, and omits filedAs when
nothing was displaced. The line above is composed for a person to read; a
program should not have to take it apart again.
Both names are matched exactly where a name is matched, and nowhere else. A
conversation with a name of its own is found by that name rather than by who is
in it, so a group called Ship Room is not reachable through a member's name or
nickname. And because a nickname is short enough to be a fragment of plenty
else, typing a whole one settles the ambiguity it creates: bob prefers the
person called exactly Bob over everyone merely containing those letters.
A group of people you have nicknames for reads as those nicknames, since a group with no name of its own is named after its members.
Accounts disagree. The same number can carry a different name in each
account, so the order they are merged in decides the winner. msg reads
ABDefaultSourceID from your Contacts preferences and visits that source first,
which is the account you actually maintain. Set MSG_CONTACTS_SOURCE to a
source UUID to prefer a different one. Source UUIDs are the directory names
under ~/Library/Application Support/AddressBook/Sources.
Contacts is read once per run, and only when names are wanted, so --no-names
costs nothing. If the databases are missing or unreadable, lookups return
nothing and messages still read normally.
cargo test # unit tests, the CLI, and the daemon over a real socket
cargo clippy --all-targets
./scripts/build.sh # both binaries, and the signed daemon bundlePoint MSG_DB at another database to develop against a fixture rather than your
own messages. The tests cover the pieces with real logic in them: the Apple
timestamp conversions and typedstream decoding in src/apple.rs, the handle
normalization in src/contacts.rs, the exit statuses in tests/cli.rs, and the
daemon end to end over a real socket in tests/daemon.rs. No test reads a real
database, every daemon test asks for names: false so none of them touches
Contacts, and the daemon tests point at a config file that does not exist so
sending stays shut.
src/
apple.rs Apple epoch conversion, typedstream decoding
contacts.rs Contacts lookup and handle normalization
db.rs read-only queries against chat.db
format.rs terminal and JSON rendering
source.rs the daemon when one is listening, the database when not
lib.rs the error type and the shapes both binaries share
bin/
msg.rs command definitions
msgd.rs the daemon process
daemon/
protocol.rs the wire: requests, frames, socket path
server.rs the daemon itself
client.rs connecting and reading answers
config.rs the one config key, read by the daemon
send.rs driving Messages.app over Apple Events
install.rs the launchd agent and where the bundle lives
tests/
cli.rs the binary as a user meets it, including exit statuses
daemon.rs the daemon over a real socket
- Reading requires Full Disk Access, which cannot be scoped to just Messages.
- Attachments cannot be listed on their own; ids come from reading or searching the conversation they are in.
- Editing, unsending, and reactions cannot be sent. Those need the private APIs, which are not reachable from AppleScript.
- Without the daemon,
watchpolls rather than subscribing, so a new message appears within one poll interval rather than instantly. - Group membership changes, read receipts, and typing indicators are not surfaced.