snappy-imessage skill
recent contact?readthread contactreadsearch textreadcontactsreadattachments contactreadsend phone textsend$ npx snappy-skills install snappy-imessage
$ npx snappy-skills install --all
$ npx snappy-skills update
You handle Snappy's iMessage channel on this Mac -- the one holding Messages.app. Blue-bubble
iMessage with SMS green-bubble fallback; osascript sends, chat.db reads. **There is no SSH hop and
no macmini alias** (D5, 2026-09-06): one computer.
A send to anyone but the owner's own phone STAGES. api.ts send <phone> <text> posts the
prepared operation to POST /hands/stage; it waits in Needs you as hand_snappy-imessage_send;
the founder's decision runs this same verb with --now. A text to the owner's own number
(SNAPPY_OWNER_PHONE, else ROBERT_PHONE) sends immediately -- he is not "anyone". Never reach
for ssh, imsg or a raw osascript send to get around that: the stage door IS the road.
typescriptimport { sendIMessage, readRecent } from "../snappy-imessage/api.ts";
Or CLI:
bashnpx tsx ~/.claude/skills/snappy-imessage/api.ts send +14155551212 "Hey, checking in!"
npx tsx ~/.claude/skills/snappy-imessage/api.ts recent # last 10 messages
npx tsx ~/.claude/skills/snappy-imessage/api.ts recent +14155551212 --limit 5 # last 5 from contact
| Function | Purpose |
|---|---|
sendIMessage(to, text) |
Send an iMessage to an E.164 phone number on THIS Mac via osascript. Unstaged — the CLI send arm owns the staging rule; call that, not this, unless you already hold a decision. |
readRecent(contact?, limit?) |
Read recent messages from chat.db, optionally filtered by contact phone number |
bash# Verify Mac Mini SSH
ssh macmini 'echo ok'
No Xano auth needed -- runs directly on Mac Mini via SSH + imsg CLI.
bash# Send by phone number
ssh macmini 'imsg send --to +14155551212 --text "Hey, checking in!"'
# Send by chat ID (for group chats / known conversations)
ssh macmini 'imsg send --chat-id 59 --text "Got it, thanks!"'
# Send with attachment (file must exist on Mac Mini)
ssh macmini 'imsg send --to +14155551212 --text "Check this" --file ~/Desktop/screenshot.png'
# Read recent messages
ssh macmini 'imsg history --chat-id 59 --limit 10 --json'
# List all conversations
ssh macmini 'imsg chats'
--json on send is a PREVIEW and touches nothing — no text, no staged row. It
prints the message inside the conversation it joins:
bashnpx tsx ~/.claude/skills/snappy-imessage/api.ts send "+14155551212" "On my way." --json
# {kind, thread, threadKind, threadTotal, draft:{to, body, asSms?}, doors:[Send, Later]}
thread is the SAME rows thread --json prints, straight out of chat.db.Show it before asking anyone to approve a text; a draft with no conversation
under it asks them to trust your summary of the conversation.
kind says which situation this is: imessage-decision when a conversationis in hand, imessage-compose for a first message to someone.
no_full_disk_access on a Mac that cannot readchat.db, exactly as every read verb does. That refusal is the honest answer —
an empty thread would draw as "you have never spoken to this person", which
is a lie about this machine's permissions dressed as a fact about the person.
asSms is carried only when the conversation's own most recent message isSMS. The bubble colour is a claim about which wire the words leave on.
--json, send is unchanged: it stages, except to the owner's ownnumber.
| ID | Number | Context |
|---|---|---|
| 59 | +16473608487 | Slack verification codes |
| 20 | +16479714727 | Recent active |
| 47 | +14153587729 | Recent active |
| Situation | Channel |
|---|---|
| Work-related, person is on Slack | snappy-slack |
| Formal (proposal, deliverable) | snappy-email |
| International or Android contact | snappy-whatsapp |
| iPhone contact, casual/personal | snappy-imessage |
| Not sure | Ask Robert |
bashssh macmini 'imsg history --chat-id 59 --limit 1 --json' | jq -r '.[0].text'
Read LATEST only -- codes expire in 5-10 min. Extract digits with grep -oE '[0-9]{6}'.
+14155551212)'\'' to escape single quotes inside single-quoted stringsscp first if needed, clean up after--chat-idlast_contacted in snappy-knowledgeimsg unavailable, fall back to osascript: ssh macmini 'osascript -e "tell application \"Messages\" to send \"hi\" to buddy \"+14155551212\""'| Symptom | Fix |
|---|---|
ssh: Could not resolve hostname macmini |
Check Tailscale: tailscale status |
imsg: command not found |
Try /usr/local/bin/imsg or osascript fallback |
Error: failed to send |
Messages.app not signed in -- Robert must re-auth |
| Attachment fails | File only on laptop -- scp to Mac Mini first |
| skill | relationship |
|---|---|
snappy-knowledge |
Contact lookup, phone numbers, birthdays, preferred_channel |
snappy-clients |
Client preferred_channel, account profiles |
snappy-calendar |
Meeting times trigger "running late" / follow-up texts |
snappy-ops |
Morning briefing routes birthday alerts here |
snappy-whatsapp |
Alternative for international/Android contacts |
snappy-infra |
Mac Mini SSH setup, Tailscale |
If this loader is insufficient, load ~/.claude/skills/snappy-imessage/SKILL.md as last resort. CLI reference: imsg-cli-reference.md.
If this loader doesn't cover your case:
bashecho "[$(date -u +%FT%TZ)] snappy-imessage: <what was missing>" >> ~/.claude/logs/agents-md-feedback.log
Show produced work with snappy-faces: call draw for image channels or lang for MCP Apps.
<!-- SKILL-INDEX-START -->
[snappy-imessage Index]|root: ~/.claude/skills/snappy-imessage|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md,imsg-cli-reference.md}
<!-- SKILL-INDEX-END -->
snappy-slack<!-- SNAPPY-CONTRACT-VERBS-START -->
Generated from api.ts HAND_CONTRACT. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
recent |
contact? |
read |
npx tsx ~/.claude/skills/snappy-imessage/api.ts recent |
thread |
contact |
read |
npx tsx ~/.claude/skills/snappy-imessage/api.ts thread <contact> |
search |
text |
read |
npx tsx ~/.claude/skills/snappy-imessage/api.ts search "<text>" |
contacts |
— | read |
npx tsx ~/.claude/skills/snappy-imessage/api.ts contacts |
attachments |
contact |
read |
npx tsx ~/.claude/skills/snappy-imessage/api.ts attachments <contact> |
send |
phone, text |
send |
npx tsx ~/.claude/skills/snappy-imessage/api.ts send <phone> "<text>" |
When an answer carries face_hint, show it with one snappy_present(<answer>) call.
See /snappy-faces for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->
---
name: snappy-imessage
role: iMessage on THIS Mac (osascript + chat.db) -- personal texts, client check-ins, SMS code retrieval
loaded-by: PreToolUse hook (auto-injected when "snappy-imessage" is mentioned)
---
# snappy-imessage -- Agent Loader
You handle Snappy's iMessage channel on **this** Mac -- the one holding Messages.app. Blue-bubble
iMessage with SMS green-bubble fallback; osascript sends, chat.db reads. **There is no SSH hop and
no `macmini` alias** (D5, 2026-09-06): one computer.
**A send to anyone but the owner's own phone STAGES.** `api.ts send <phone> <text>` posts the
prepared operation to `POST /hands/stage`; it waits in Needs you as `hand_snappy-imessage_send`;
the founder's decision runs this same verb with `--now`. A text to the owner's own number
(`SNAPPY_OWNER_PHONE`, else `ROBERT_PHONE`) sends immediately -- he is not "anyone". Never reach
for `ssh`, `imsg` or a raw osascript send to get around that: the stage door IS the road.
## API module
```typescript
import { sendIMessage, readRecent } from "../snappy-imessage/api.ts";
```
Or CLI:
```bash
npx tsx ~/.claude/skills/snappy-imessage/api.ts send +14155551212 "Hey, checking in!"
npx tsx ~/.claude/skills/snappy-imessage/api.ts recent # last 10 messages
npx tsx ~/.claude/skills/snappy-imessage/api.ts recent +14155551212 --limit 5 # last 5 from contact
```
## API functions
| Function | Purpose |
|----------|---------|
| `sendIMessage(to, text)` | Send an iMessage to an E.164 phone number on THIS Mac via osascript. Unstaged — the CLI `send` arm owns the staging rule; call that, not this, unless you already hold a decision. |
| `readRecent(contact?, limit?)` | Read recent messages from chat.db, optionally filtered by contact phone number |
## Required environment
```bash
# Verify Mac Mini SSH
ssh macmini 'echo ok'
```
No Xano auth needed -- runs directly on Mac Mini via SSH + `imsg` CLI.
---
## Core commands
```bash
# Send by phone number
ssh macmini 'imsg send --to +14155551212 --text "Hey, checking in!"'
# Send by chat ID (for group chats / known conversations)
ssh macmini 'imsg send --chat-id 59 --text "Got it, thanks!"'
# Send with attachment (file must exist on Mac Mini)
ssh macmini 'imsg send --to +14155551212 --text "Check this" --file ~/Desktop/screenshot.png'
# Read recent messages
ssh macmini 'imsg history --chat-id 59 --limit 10 --json'
# List all conversations
ssh macmini 'imsg chats'
```
## The draft never arrives alone
`--json` on `send` is a PREVIEW and touches nothing — no text, no staged row. It
prints the message **inside the conversation it joins**:
```bash
npx tsx ~/.claude/skills/snappy-imessage/api.ts send "+14155551212" "On my way." --json
# {kind, thread, threadKind, threadTotal, draft:{to, body, asSms?}, doors:[Send, Later]}
```
- `thread` is the SAME rows `thread --json` prints, straight out of chat.db.
Show it before asking anyone to approve a text; a draft with no conversation
under it asks them to trust your summary of the conversation.
- `kind` says which situation this is: `imessage-decision` when a conversation
is in hand, `imessage-compose` for a first message to someone.
- The preview REFUSES with `no_full_disk_access` on a Mac that cannot read
chat.db, exactly as every read verb does. That refusal is the honest answer —
an empty thread would draw as "you have never spoken to this person", which
is a lie about this machine's permissions dressed as a fact about the person.
- `asSms` is carried only when the conversation's own most recent message is
SMS. The bubble colour is a claim about which wire the words leave on.
- WITHOUT `--json`, `send` is unchanged: it stages, except to the owner's own
number.
## Known chat IDs
| ID | Number | Context |
|----|--------|---------|
| 59 | +16473608487 | Slack verification codes |
| 20 | +16479714727 | Recent active |
| 47 | +14153587729 | Recent active |
## Channel decision tree (run FIRST)
| Situation | Channel |
|-----------|---------|
| Work-related, person is on Slack | `snappy-slack` |
| Formal (proposal, deliverable) | `snappy-email` |
| International or Android contact | `snappy-whatsapp` |
| iPhone contact, casual/personal | **snappy-imessage** |
| Not sure | Ask Robert |
## SMS verification codes
```bash
ssh macmini 'imsg history --chat-id 59 --limit 1 --json' | jq -r '.[0].text'
```
Read LATEST only -- codes expire in 5-10 min. Extract digits with `grep -oE '[0-9]{6}'`.
## Rules
- Phone numbers MUST be E.164 (`+14155551212`)
- Quote escaping over SSH: use `'\''` to escape single quotes inside single-quoted strings
- Files must exist on Mac Mini -- `scp` first if needed, clean up after
- Cannot create group chats -- only reply to existing via `--chat-id`
- Birthday/personal messages ALWAYS require Robert's confirmation before sending
- After sending, update `last_contacted` in `snappy-knowledge`
- If `imsg` unavailable, fall back to osascript: `ssh macmini 'osascript -e "tell application \"Messages\" to send \"hi\" to buddy \"+14155551212\""'`
## Error recovery
| Symptom | Fix |
|---------|-----|
| `ssh: Could not resolve hostname macmini` | Check Tailscale: `tailscale status` |
| `imsg: command not found` | Try `/usr/local/bin/imsg` or osascript fallback |
| `Error: failed to send` | Messages.app not signed in -- Robert must re-auth |
| Attachment fails | File only on laptop -- `scp` to Mac Mini first |
## Uses
| skill | relationship |
|-------|-------------|
| `snappy-knowledge` | Contact lookup, phone numbers, birthdays, `preferred_channel` |
| `snappy-clients` | Client `preferred_channel`, account profiles |
| `snappy-calendar` | Meeting times trigger "running late" / follow-up texts |
| `snappy-ops` | Morning briefing routes birthday alerts here |
| `snappy-whatsapp` | Alternative for international/Android contacts |
| `snappy-infra` | Mac Mini SSH setup, Tailscale |
---
## Full skill reference
If this loader is insufficient, load `~/.claude/skills/snappy-imessage/SKILL.md` as last resort. CLI reference: [imsg-cli-reference.md](imsg-cli-reference.md).
If this loader doesn't cover your case:
```bash
echo "[$(date -u +%FT%TZ)] snappy-imessage: <what was missing>" >> ~/.claude/logs/agents-md-feedback.log
```
Show produced work with `snappy-faces`: call `draw` for image channels or `lang` for MCP Apps.
<!-- SKILL-INDEX-START -->
[snappy-imessage Index]|root: ~/.claude/skills/snappy-imessage|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md,imsg-cli-reference.md}
<!-- SKILL-INDEX-END -->
## Used by
- `snappy-slack`
<!-- SNAPPY-CONTRACT-VERBS-START -->
## Contract verbs
Generated from `api.ts` `HAND_CONTRACT`. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
| `recent` | `contact?` | `read` | `npx tsx ~/.claude/skills/snappy-imessage/api.ts recent` |
| `thread` | `contact` | `read` | `npx tsx ~/.claude/skills/snappy-imessage/api.ts thread <contact>` |
| `search` | `text` | `read` | `npx tsx ~/.claude/skills/snappy-imessage/api.ts search "<text>"` |
| `contacts` | — | `read` | `npx tsx ~/.claude/skills/snappy-imessage/api.ts contacts` |
| `attachments` | `contact` | `read` | `npx tsx ~/.claude/skills/snappy-imessage/api.ts attachments <contact>` |
| `send` | `phone`, `text` | `send` | `npx tsx ~/.claude/skills/snappy-imessage/api.ts send <phone> "<text>"` |
## Show the result
When an answer carries `face_hint`, show it with one `snappy_present(<answer>)` call.
See `/snappy-faces` for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->
Three files and nothing else: SKILL.md, AGENTS.md, api.ts, plus the reader
chat-db.ts and a generated test fixture that sit beside them inside this skill.
Everything goes through api.ts. There is no bash helper, no python script and
no second implementation.
bashnpx tsx ~/.claude/skills/snappy-imessage/api.ts recent --limit 5 --json
typescriptimport { readRecent, sendIMessage } from "../snappy-imessage/api.ts";
This hand reads Messages' own SQLite database with node:sqlite, which is built
into node 22.5 and later. Nothing is installed, nothing is compiled, and no file
outside this skill is consulted.
The hand runs on the Mac that is holding Messages. There is no hostname
test, no mini alias and no SSH branch — a hand that reaches for "the other
machine" cannot run on a laptop.
chat.db has thereal conversations. A tenant row points a run at it.
~/Library/Messages/chat.db does notexist, and every read refuses with no_messages_on_this_mac — measured, not
guessed, because the file is either there or it is not.
This is the real requirement, and it is not a credential. There is no key to
put in .env.cache; macOS gates ~/Library/Messages behind a per-application
grant. That is why HAND_CONTRACT.requires is empty and the grant is declared
under capabilities instead.
Without the grant, macOS refuses at access(2) before any file is opened, so
the hand answers in well under a millisecond:
json{
"outcome": "refused",
"code": "no_full_disk_access",
"message": "This process cannot read /Users/…/Library/Messages/chat.db. macOS protects Messages behind Full Disk Access, and this runner has not been granted it.",
"fix": "System Settings → Privacy & Security → Full Disk Access → add the app that runs this (Terminal, or SnappyOS), toggle it on, then quit and reopen that app and run this again."
}
The grant belongs to the application that launches node, not to node. After
adding it, that application must be quit and reopened; the grant is read at
process start.
| Verb | Words | Effect | Answers |
|---|---|---|---|
recent |
[contact] |
read | The newest messages, across every conversation or one person's |
thread |
<contact> |
read | One conversation, oldest first, as imessage-thread |
search |
<text> |
read | Messages whose words contain a phrase |
contacts |
— | read | The handles this Mac has messages with, busiest first |
attachments |
<contact> |
read | File names, paths and sizes in one conversation |
send |
<phone> <text> |
send | Stages for a decision; sends only to the owner's own number |
Every read takes --limit N (default 10, ceiling 200) and --json.
The count is a flag, never a positional. recent 5 used to file the 5 as a
contact and answer ten messages of a person who does not exist; it now refuses
by name and prints recent 5 --limit 5.
contact matches loosely: an E.164 phone, an Apple ID email, or part of the
conversation's name as Messages titles it.
Plain output is one tab-separated line per message: date, sender, text. me in
the sender column means the message went out.
--json prints the object the imessage-thread face draws:
json{
"kind": "imessage-thread",
"messages": [
{ "id": "…", "text": "Are you still coming by?", "date": "2026-09-04T15:02:00.000Z",
"outgoing": false, "from": "+15550100", "service": "sms",
"receipt": null, "reaction": "❤️" }
],
"chat": "Mara Quill",
"total": 5
}
service, receipt and reaction come off the columns that hold them, so the
conversation draws the way Messages draws it — green SMS bubbles, "Read" under
the last thing sent, a tapback on the message it was left on.
A contactless recent takes no face. With no contact it answers the newest
messages across every conversation — several people at once. Drawing that as one
thread would put three people's words in one conversation with left and right
bubbles implying a single correspondent. snappy-faces wires four iMessage
kinds (one, thread, compose, decision) and no list, so a contactless `recent
--json prints {count, messages} and names no kind. contacts` is the verb
that answers "who has been texting".
send goes through Messages by AppleEvent, which needs the Automation grant
(System Settings → Privacy & Security → Automation). Denied, the event hangs or
answers -1743; the hand turns that into one sentence naming the pane.
A send to anybody but the owner stages. Without --now, the operation goes
to the stage door and a person decides; the decision runs this same verb with
--now. There is deliberately no separate stage verb, so a caller cannot pick
an arm that skips the decision.
The owner himself is not "anyone." A text to SNAPPY_OWNER_PHONE (or
ROBERT_PHONE) is the owner being told what he asked to be told: it sends, and
the receipt is the proof. With neither key set, every send stages — a missing
key must never widen what may send.
Each is a typed envelope with outcome, code, message and fix, printed on
stderr with exit 1. A read that cannot run must never look like a read that
found nothing.
| Code | Means | Fix |
|---|---|---|
no_full_disk_access |
macOS is withholding chat.db | Grant Full Disk Access to the launching app, then reopen it |
no_messages_on_this_mac |
No Messages database here | Run on the Mac signed in to Messages |
no_node_sqlite |
The runtime predates node:sqlite |
Run on node 22.5 or newer |
chat_db_locked |
Messages is holding the file | Run again shortly, then quit Messages |
automation_not_allowed |
The AppleEvent grant is missing | Allow Messages under Automation |
bad_phone_number |
Not E.164 | Write it as +14155551212 |
missing_argument |
A contract word was not supplied | Supply the named argument |
unknown_verb |
No such verb | Use one of the six above |
chat-db.ts is the only reader. The details worth knowing before changing it:
message.date is nanoseconds since 2001-01-01on macOS 10.13 and later, and seconds on anything carried over from before.
Magnitude is the discriminant. A reader that assumes one dates half an archive
to 2001.
node:sqlite throws on an integer above 2^53,and a nanosecond stamp is about 8.1e17, so every date column is `CAST … AS
TEXT. Without this, every read of a modern database threw ERR_OUT_OF_RANGE`.
text. Modern iMessage keeps the body inattributedBody, an Apple typedstream archive. The decoder handles the
single-string case, which is every plain message, and answers null rather than
garbage for anything else.
found among the run — when tapbacks shared the run's limit, --limit 3
answered one message. A removal row cancels the add, so a reaction someone
took off is not still on the message.
display_name is empty, not null, for a one-to-one chat, so the headerfalls through NULLIF to chat_identifier.
decodes the newest 4000 blob-only messages. The answer carries scanned and
truncated, so a caller can see the window it covered.
The database is opened read-only. Nothing in this skill writes to it.
bashnpx tsx --test ~/.claude/skills/snappy-imessage/api.test.ts
npx tsx --test ~/.claude/skills/snappy-imessage/face.test.ts
api.test.ts builds a real SQLite database with Messages' own schema
(fixtures/build-chat-db.ts) into a temp directory and runs the real SQL
against it. The people in it are invented — Mara Quill, +1 555 0100 — and the
owner's own conversations are never read by a test and never committed.
face.test.ts validates the printed object against the zod props the face
itself declares, loaded through skills/hand-face-props.ts. It needs zod
resolvable from snappy-faces/library; without it the face tests of every hand
in the collection fail the same way.
snappy-whatsapp — the same conversations shape over Meta's Cloud API.snappy-telegram — the Bot API channel, and the face-channel reference.snappy-outbound — the router that picks a channel per contact.snappy-faces — draws imessage-thread; snappy-hands runs this as a hand.snappy-tool-design — the lint this contract answers to.snappy-desktop, snappy-ax, snappy-cleanshot, snappy-agent-host — otherhands that act on this Mac rather than over a network.
snappy-gmail, snappy-linkedin, snappy-slack, snappy-freshbooks,snappy-github, snappy-gateway, snappy-resident — hands whose reads take
the same face-shaped --json.
snappy-infra, snappy-os-operator — the plumbing and the operator planebehind a Snappy connector; this hand needs neither.
snappy-transcripts, snappy-testimonials — the other readers of what peoplesaid, from calls rather than from texts.
Skill Status: COMPLETE
---
name: snappy-imessage
description: >
Reads and sends iMessage and SMS on the Mac signed in to Messages, straight out of chat.db
with node's own SQLite: recent messages, one person's thread, a phrase search, the handles
seen, and attachment paths. A send to anyone but the owner stages for his decision. Needs
macOS Full Disk Access rather than a credential, unlike snappy-whatsapp and snappy-telegram.
Use when asked what someone texted, to search the message archive, or to text a phone number.
Triggers on: imessage, text message, sms, Messages app, chat.db, text someone, what did they text.
---
# snappy-imessage
Three files and nothing else: `SKILL.md`, `AGENTS.md`, `api.ts`, plus the reader
`chat-db.ts` and a generated test fixture that sit beside them inside this skill.
## Contents
- [The one road](#the-one-road)
- [Which Mac](#which-mac)
- [Full Disk Access](#full-disk-access)
- [Verbs](#verbs)
- [What a read answers](#what-a-read-answers)
- [Sending](#sending)
- [Refusals](#refusals)
- [How chat.db is read](#how-chatdb-is-read)
- [Tests](#tests)
- [Related skills](#related-skills)
## The one road
Everything goes through `api.ts`. There is no bash helper, no python script and
no second implementation.
```bash
npx tsx ~/.claude/skills/snappy-imessage/api.ts recent --limit 5 --json
```
```typescript
import { readRecent, sendIMessage } from "../snappy-imessage/api.ts";
```
This hand reads Messages' own SQLite database with `node:sqlite`, which is built
into node 22.5 and later. Nothing is installed, nothing is compiled, and no file
outside this skill is consulted.
## Which Mac
**The hand runs on the Mac that is holding Messages.** There is no hostname
test, no `mini` alias and no SSH branch — a hand that reaches for "the other
machine" cannot run on a laptop.
- Messages is signed in on the **Mac Mini**, and that is where `chat.db` has the
real conversations. A tenant row points a run at it.
- On a Mac with no Messages account, `~/Library/Messages/chat.db` does not
exist, and every read refuses with `no_messages_on_this_mac` — measured, not
guessed, because the file is either there or it is not.
## Full Disk Access
**This is the real requirement, and it is not a credential.** There is no key to
put in `.env.cache`; macOS gates `~/Library/Messages` behind a per-application
grant. That is why `HAND_CONTRACT.requires` is empty and the grant is declared
under `capabilities` instead.
Without the grant, macOS refuses at `access(2)` before any file is opened, so
the hand answers in well under a millisecond:
```json
{
"outcome": "refused",
"code": "no_full_disk_access",
"message": "This process cannot read /Users/…/Library/Messages/chat.db. macOS protects Messages behind Full Disk Access, and this runner has not been granted it.",
"fix": "System Settings → Privacy & Security → Full Disk Access → add the app that runs this (Terminal, or SnappyOS), toggle it on, then quit and reopen that app and run this again."
}
```
The grant belongs to the **application that launches node**, not to node. After
adding it, that application must be quit and reopened; the grant is read at
process start.
## Verbs
| Verb | Words | Effect | Answers |
|---|---|---|---|
| `recent` | `[contact]` | read | The newest messages, across every conversation or one person's |
| `thread` | `<contact>` | read | One conversation, oldest first, as `imessage-thread` |
| `search` | `<text>` | read | Messages whose words contain a phrase |
| `contacts` | — | read | The handles this Mac has messages with, busiest first |
| `attachments` | `<contact>` | read | File names, paths and sizes in one conversation |
| `send` | `<phone> <text>` | send | Stages for a decision; sends only to the owner's own number |
Every read takes `--limit N` (default 10, ceiling 200) and `--json`.
**The count is a flag, never a positional.** `recent 5` used to file the `5` as a
contact and answer ten messages of a person who does not exist; it now refuses
by name and prints `recent 5 --limit 5`.
**`contact` matches loosely**: an E.164 phone, an Apple ID email, or part of the
conversation's name as Messages titles it.
## What a read answers
Plain output is one tab-separated line per message: date, sender, text. `me` in
the sender column means the message went out.
`--json` prints the object the `imessage-thread` face draws:
```json
{
"kind": "imessage-thread",
"messages": [
{ "id": "…", "text": "Are you still coming by?", "date": "2026-09-04T15:02:00.000Z",
"outgoing": false, "from": "+15550100", "service": "sms",
"receipt": null, "reaction": "❤️" }
],
"chat": "Mara Quill",
"total": 5
}
```
`service`, `receipt` and `reaction` come off the columns that hold them, so the
conversation draws the way Messages draws it — green SMS bubbles, "Read" under
the last thing sent, a tapback on the message it was left on.
**A contactless `recent` takes no face.** With no contact it answers the newest
messages across every conversation — several people at once. Drawing that as one
thread would put three people's words in one conversation with left and right
bubbles implying a single correspondent. `snappy-faces` wires four iMessage
kinds (one, thread, compose, decision) and no list, so a contactless `recent
--json` prints `{count, messages}` and names no kind. `contacts` is the verb
that answers "who has been texting".
## Sending
`send` goes through Messages by AppleEvent, which needs the **Automation** grant
(System Settings → Privacy & Security → Automation). Denied, the event hangs or
answers -1743; the hand turns that into one sentence naming the pane.
**A send to anybody but the owner stages.** Without `--now`, the operation goes
to the stage door and a person decides; the decision runs this same verb with
`--now`. There is deliberately no separate `stage` verb, so a caller cannot pick
an arm that skips the decision.
**The owner himself is not "anyone."** A text to `SNAPPY_OWNER_PHONE` (or
`ROBERT_PHONE`) is the owner being told what he asked to be told: it sends, and
the receipt is the proof. With neither key set, every send stages — a missing
key must never widen what may send.
## Refusals
Each is a typed envelope with `outcome`, `code`, `message` and `fix`, printed on
stderr with exit 1. A read that cannot run must never look like a read that
found nothing.
| Code | Means | Fix |
|---|---|---|
| `no_full_disk_access` | macOS is withholding chat.db | Grant Full Disk Access to the launching app, then reopen it |
| `no_messages_on_this_mac` | No Messages database here | Run on the Mac signed in to Messages |
| `no_node_sqlite` | The runtime predates `node:sqlite` | Run on node 22.5 or newer |
| `chat_db_locked` | Messages is holding the file | Run again shortly, then quit Messages |
| `automation_not_allowed` | The AppleEvent grant is missing | Allow Messages under Automation |
| `bad_phone_number` | Not E.164 | Write it as `+14155551212` |
| `missing_argument` | A contract word was not supplied | Supply the named argument |
| `unknown_verb` | No such verb | Use one of the six above |
## How chat.db is read
`chat-db.ts` is the only reader. The details worth knowing before changing it:
- **Two clocks in one column.** `message.date` is nanoseconds since 2001-01-01
on macOS 10.13 and later, and seconds on anything carried over from before.
Magnitude is the discriminant. A reader that assumes one dates half an archive
to 2001.
- **The dates are read as text.** `node:sqlite` throws on an integer above 2^53,
and a nanosecond stamp is about 8.1e17, so every date column is `CAST … AS
TEXT`. Without this, every read of a modern database threw `ERR_OUT_OF_RANGE`.
- **The words are often not in `text`.** Modern iMessage keeps the body in
`attributedBody`, an Apple typedstream archive. The decoder handles the
single-string case, which is every plain message, and answers null rather than
garbage for anything else.
- **A tapback is a message row.** It is fetched by the guid it points at, never
found among the run — when tapbacks shared the run's limit, `--limit 3`
answered one message. A removal row cancels the add, so a reaction someone
took off is not still on the message.
- **`display_name` is empty, not null, for a one-to-one chat**, so the header
falls through `NULLIF` to `chat_identifier`.
- **Search runs twice.** SQL matches the plain column; a second bounded pass
decodes the newest 4000 blob-only messages. The answer carries `scanned` and
`truncated`, so a caller can see the window it covered.
The database is opened read-only. Nothing in this skill writes to it.
## Tests
```bash
npx tsx --test ~/.claude/skills/snappy-imessage/api.test.ts
npx tsx --test ~/.claude/skills/snappy-imessage/face.test.ts
```
`api.test.ts` builds a real SQLite database with Messages' own schema
(`fixtures/build-chat-db.ts`) into a temp directory and runs the real SQL
against it. The people in it are invented — Mara Quill, `+1 555 0100` — and the
owner's own conversations are never read by a test and never committed.
`face.test.ts` validates the printed object against the zod props the face
itself declares, loaded through `skills/hand-face-props.ts`. It needs `zod`
resolvable from `snappy-faces/library`; without it the face tests of every hand
in the collection fail the same way.
## Related skills
- `snappy-whatsapp` — the same conversations shape over Meta's Cloud API.
- `snappy-telegram` — the Bot API channel, and the face-channel reference.
- `snappy-outbound` — the router that picks a channel per contact.
- `snappy-faces` — draws `imessage-thread`; `snappy-hands` runs this as a hand.
- `snappy-tool-design` — the lint this contract answers to.
- `snappy-desktop`, `snappy-ax`, `snappy-cleanshot`, `snappy-agent-host` — other
hands that act on this Mac rather than over a network.
- `snappy-gmail`, `snappy-linkedin`, `snappy-slack`, `snappy-freshbooks`,
`snappy-github`, `snappy-gateway`, `snappy-resident` — hands whose reads take
the same face-shaped `--json`.
- `snappy-infra`, `snappy-os-operator` — the plumbing and the operator plane
behind a Snappy connector; this hand needs neither.
- `snappy-transcripts`, `snappy-testimonials` — the other readers of what people
said, from calls rather than from texts.
**Skill Status**: COMPLETE
// snappy-imessage/adapter.ts — iMessage ChannelAdapter
// readRecent() returns tab-separated sqlite output: ts\tsender\ttext
// Thread model: synthesize thread_id from sender handle.
import { realpathSync } from "fs";
// NO PARSER AT ALL ANY MORE ⟨2026-09-09⟩. This file used to split the python
// reader's `ts\tsender\ttext` lines itself, then imported api.ts's copy of that
// split. Now `readRecent` answers TYPED ROWS straight out of chat.db, so there
// is no wire format between the reader and this adapter to read two ways
// ⟨CLAUDE.md §4⟩ — the duplicate road is gone rather than deduplicated.
import { readRecent, sendIMessage } from "./api.ts";
import { runSelfCheck } from "../snappy-channel-contract/verify.ts";
import type {
ChannelAdapter, Event, PostTarget, PostContent, PostResult, Contact, SelfCheckResult,
} from "../snappy-channel-contract/types.ts";
export const adapter: ChannelAdapter = {
source: "imessage",
async read(since, limit = 20): Promise<Event[]> {
// `since` is applied HERE rather than in SQL: the reader answers the newest
// `limit` messages, and this channel wants the ones after a watermark. A
// read that quietly ignored `since` would replay the same arrivals forever.
const sinceMs = since ? new Date(since).getTime() : 0;
return readRecent(undefined, limit)
.filter((row) => !row.outgoing)
.filter((row) => (row.text ?? "").trim().length > 0)
.filter((row) => row.date !== null && new Date(row.date).getTime() > sinceMs)
.map((row) => ({
source: "imessage" as const,
// chat.db's own guid, so one message keeps one id on every road.
event_id: row.id,
thread_id: row.from ?? "unknown",
channel_id: row.from ?? "unknown",
channel_name: row.chat ?? row.from ?? "unknown",
author: { id: row.from ?? "unknown", handle: row.from ?? "unknown", display: row.chat ?? row.from ?? "unknown" },
text: row.text ?? "",
ts: row.date ?? new Date(0).toISOString(),
permalink: null,
meta: { service: row.service, attachments: row.attachments },
}));
},
async post(target: PostTarget, content: PostContent): Promise<PostResult> {
try {
const to = target.to_user ?? target.channel_id;
const out = sendIMessage(to, content.text);
return { ok: true, posted_id: null, permalink: null, error: out.includes("error") ? out : undefined };
} catch (e) {
return { ok: false, posted_id: null, permalink: null, error: (e as Error).message };
}
},
async identify(authorId: string): Promise<Contact | null> {
if (!authorId) return null;
return { id: authorId, handle: authorId, display: authorId, profile_url: null, meta: {} };
},
async selfCheck(): Promise<SelfCheckResult> { return runSelfCheck(this); },
};
export default adapter;
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
(async () => { console.log(JSON.stringify(await adapter.selfCheck(), null, 2)); })();
}
// snappy-imessage/adapter.ts — iMessage ChannelAdapter
// readRecent() returns tab-separated sqlite output: ts\tsender\ttext
// Thread model: synthesize thread_id from sender handle.
import { realpathSync } from "fs";
// NO PARSER AT ALL ANY MORE ⟨2026-09-09⟩. This file used to split the python
// reader's `ts\tsender\ttext` lines itself, then imported api.ts's copy of that
// split. Now `readRecent` answers TYPED ROWS straight out of chat.db, so there
// is no wire format between the reader and this adapter to read two ways
// ⟨CLAUDE.md §4⟩ — the duplicate road is gone rather than deduplicated.
import { readRecent, sendIMessage } from "./api.ts";
import { runSelfCheck } from "../snappy-channel-contract/verify.ts";
import type {
ChannelAdapter, Event, PostTarget, PostContent, PostResult, Contact, SelfCheckResult,
} from "../snappy-channel-contract/types.ts";
export const adapter: ChannelAdapter = {
source: "imessage",
async read(since, limit = 20): Promise<Event[]> {
// `since` is applied HERE rather than in SQL: the reader answers the newest
// `limit` messages, and this channel wants the ones after a watermark. A
// read that quietly ignored `since` would replay the same arrivals forever.
const sinceMs = since ? new Date(since).getTime() : 0;
return readRecent(undefined, limit)
.filter((row) => !row.outgoing)
.filter((row) => (row.text ?? "").trim().length > 0)
.filter((row) => row.date !== null && new Date(row.date).getTime() > sinceMs)
.map((row) => ({
source: "imessage" as const,
// chat.db's own guid, so one message keeps one id on every road.
event_id: row.id,
thread_id: row.from ?? "unknown",
channel_id: row.from ?? "unknown",
channel_name: row.chat ?? row.from ?? "unknown",
author: { id: row.from ?? "unknown", handle: row.from ?? "unknown", display: row.chat ?? row.from ?? "unknown" },
text: row.text ?? "",
ts: row.date ?? new Date(0).toISOString(),
permalink: null,
meta: { service: row.service, attachments: row.attachments },
}));
},
async post(target: PostTarget, content: PostContent): Promise<PostResult> {
try {
const to = target.to_user ?? target.channel_id;
const out = sendIMessage(to, content.text);
return { ok: true, posted_id: null, permalink: null, error: out.includes("error") ? out : undefined };
} catch (e) {
return { ok: false, posted_id: null, permalink: null, error: (e as Error).message };
}
},
async identify(authorId: string): Promise<Contact | null> {
if (!authorId) return null;
return { id: authorId, handle: authorId, display: authorId, profile_url: null, meta: {} };
},
async selfCheck(): Promise<SelfCheckResult> { return runSelfCheck(this); },
};
export default adapter;
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
(async () => { console.log(JSON.stringify(await adapter.selfCheck(), null, 2)); })();
}
/**
* THE READER IS PROVED AGAINST MESSAGES' OWN SCHEMA.
*
* Every test below runs the real SQL in `chat-db.ts` against a real SQLite
* database built by `fixtures/build-chat-db.ts` — the same tables, columns and
* joins Messages writes, with nobody real in it. That is the only honest way to
* test a reader: a mocked row proves the mock.
*
* NOTHING HERE TOUCHES THE OWNER'S MESSAGES. The fixture is generated into a
* temp directory at test time and thrown away; the real chat.db is never opened
* (measured 2026-09-09: this runner cannot open it at all — Full Disk Access is
* denied — which is what the refusal tests assert).
*/
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
HAND_CONTRACT, imessageFaceForVerb, parseRecentMessagesArgs,
} from "./api.ts";
import {
LIMIT, appleDateToIso, attachmentsFor, clampLimit, decodeAttributedBody, fullDiskAccessRefusal,
openChatDb, readContacts, readRecent, searchMessages, tapbackEmoji, tapbackTargetGuid,
} from "./chat-db.ts";
import { DEV, MARA, buildFixtureChatDb, typedStream } from "./fixtures/build-chat-db.ts";
import { exampleHazard } from "../snappy-tool-design/api.ts";
const DB = buildFixtureChatDb(join(mkdtempSync(join(tmpdir(), "snappy-imessage-")), "chat.db"));
/** Every read answers `{rows}` or `{refusal}`; a test that reached for `.rows`
* on a refusal would read `undefined` and pass vacuously, which is the exact
* failure CLAUDE.md calls "a refusal reported as an acceptance". */
function rows<T>(answer: { rows: T } | { refusal: unknown }): T {
assert.ok("rows" in answer, `expected rows, got ${JSON.stringify(answer)}`);
return answer.rows;
}
/* ── THE READER, INSIDE THE SKILL ─────────────────────────────────────────── */
test("a thread reads out of chat.db with no file outside these three", () => {
const thread = rows(readRecent(MARA, 10, DB));
// Oldest first, which is how a conversation reads.
assert.deepEqual(thread.map((row) => row.id), ["guid-8", "guid-1", "guid-2", "guid-3", "guid-4"]);
assert.equal(thread[1].text, "Are you still coming by the workshop?");
assert.equal(thread[1].from, MARA);
assert.equal(thread[1].outgoing, false);
// The second correspondent's message is NOT in this thread.
assert.ok(!thread.some((row) => row.text === "Deploy is green."));
});
test("the words come out of attributedBody when the text column is null", () => {
const thread = rows(readRecent(MARA, 10, DB));
const blobRow = thread.find((row) => row.id === "guid-3");
// THIS IS WHY THE PYTHON READER EXISTED. Modern iMessage keeps the message in
// a typedstream archive, and a reader that only selects `text` answers null
// for most of a real conversation.
assert.match(blobRow?.text ?? "", /^No rush at all — the glue is still curing/);
});
test("the decoder answers null rather than garbage when it cannot read a blob", () => {
assert.equal(decodeAttributedBody(null), null);
assert.equal(decodeAttributedBody(Buffer.alloc(0)), null);
assert.equal(decodeAttributedBody(Buffer.from("no class name here", "utf8")), null);
// A declared length running off the end of the buffer is refused, not read.
assert.equal(decodeAttributedBody(Buffer.from("NSString\x01\x94\x84\x01+\x40short", "latin1")), null);
// Short and long strings both round-trip; the long one takes the 0x81 branch.
assert.equal(decodeAttributedBody(typedStream("hello")), "hello");
const long = "x".repeat(400);
assert.equal(decodeAttributedBody(typedStream(long)), long);
assert.equal(decodeAttributedBody(typedStream("émoji 🚀 unicode")), "émoji 🚀 unicode");
});
test("both of Apple's clocks are read, so an archive is not dated to 2001", () => {
// Nanoseconds since 2001-01-01 (macOS 10.13 and later).
assert.equal(appleDateToIso("810228120000000000"), "2026-09-04T15:22:00.000Z");
// Seconds since 2001-01-01, the older encoding a carried-over database holds.
assert.equal(appleDateToIso("478602900"), "2016-03-02T09:15:00.000Z");
// Zero is "never" (an unset date_read), not the year 2001.
assert.equal(appleDateToIso(0), null);
assert.equal(appleDateToIso(null), null);
const archive = rows(readRecent(MARA, 10, DB)).find((row) => row.id === "guid-8");
assert.equal(archive?.date, "2016-03-02T09:15:00.000Z");
});
test("the blue-green bubble and the receipt ladder are read, not left null", () => {
const thread = rows(readRecent(MARA, 10, DB));
const byId = new Map(thread.map((row) => [row.id, row]));
// The retired reader printed three columns, so these three were ALWAYS null
// and every conversation drew flat.
assert.equal(byId.get("guid-1")?.service, "sms");
assert.equal(byId.get("guid-2")?.service, "imessage");
assert.equal(byId.get("guid-2")?.receipt, "read");
assert.equal(byId.get("guid-4")?.receipt, "delivered");
// An INCOMING message carries no receipt: nothing in this database says
// whether they read ours except our own copy.
assert.equal(byId.get("guid-1")?.receipt, null);
});
test("a tapback folds onto its message, and its removal cancels it", () => {
const byId = new Map(rows(readRecent(MARA, 10, DB)).map((row) => [row.id, row]));
assert.equal(byId.get("guid-4")?.reaction, "❤️");
// guid-1 was liked and then un-liked; a reader that only reads 2000-2005
// leaves a thumbs-up on a message the person took it off.
assert.equal(byId.get("guid-1")?.reaction, null);
// And a tapback is never a bubble of its own.
assert.ok(!byId.has("guid-5"));
assert.ok(!byId.has("guid-9"));
assert.equal(tapbackEmoji(2000), "❤️");
assert.equal(tapbackEmoji(3000), null);
assert.equal(tapbackEmoji(0), null);
assert.equal(tapbackTargetGuid("p:0/guid-4"), "guid-4");
assert.equal(tapbackTargetGuid("bp:guid-4"), "guid-4");
assert.equal(tapbackTargetGuid(null), null);
});
test("the limit counts MESSAGES, never the tapback rows beside them", () => {
// MEASURED 2026-09-09 before this fix: `--limit 3` answered ONE message,
// because two of the three newest rows were a tapback and its removal.
assert.equal(rows(readRecent(MARA, 3, DB)).length, 3);
assert.equal(rows(readRecent(undefined, 2, DB)).length, 2);
// The ask is clamped rather than obeyed without bound, and a bad ask falls
// to the declared default instead of answering nothing.
assert.equal(clampLimit(10_000), LIMIT.max);
assert.equal(clampLimit(0), LIMIT.default);
assert.equal(clampLimit(undefined), LIMIT.default);
});
test("search reaches the words that live in a blob, and says how far it looked", () => {
// "glue" is ONLY in the attributedBody row, so a SQL-only search finds
// nothing — which is what this verb did before the second pass.
const found = rows(searchMessages("glue", 5, DB));
assert.equal(found.rows.length, 1);
assert.equal(found.rows[0].id, "guid-3");
assert.equal(found.truncated, false);
// The plain column still matches, case-insensitively.
assert.equal(rows(searchMessages("DEPLOY", 5, DB)).rows[0]?.id, "guid-7");
// A phrase nobody wrote answers an empty list, which is an ANSWER.
assert.equal(rows(searchMessages("zzz-nobody-wrote-this", 5, DB)).rows.length, 0);
});
test("contacts answers handles Messages itself holds, and counts only messages", () => {
const people = rows(readContacts(10, DB));
assert.deepEqual(people.map((person) => person.handle), [DEV, MARA]);
const mara = people.find((person) => person.handle === MARA);
assert.equal(mara?.chat, "Mara Quill");
// Five messages, not seven: the two tapback rows are not messages.
assert.equal(mara?.messages, 5);
assert.equal(mara?.last, "2026-09-04T15:20:00.000Z");
});
test("attachments answers paths only and never opens a file", () => {
const files = rows(attachmentsFor(MARA, 10, DB));
assert.equal(files.length, 1);
assert.equal(files[0].name, "cut-list.pdf");
assert.equal(files[0].mime, "application/pdf");
assert.equal(files[0].bytes, 51_234);
assert.equal(files[0].outgoing, true);
});
/* ── THE REFUSALS, EVERY CODE IN THE CONTRACT'S CLOSED TABLE ──────────────── */
test("a Mac with no Messages database refuses by name: service_unavailable", () => {
const refusal = fullDiskAccessRefusal("/nowhere/at/all/chat.db");
assert.equal(refusal?.outcome, "refused");
assert.equal(refusal?.code, "service_unavailable");
// The MESSAGE is the hand's own sentence about THIS failure; `fix` comes
// from the one table, so the machine-readable next step can never drift.
assert.match(refusal?.message ?? "", /Mac that is signed in to Messages/i);
assert.equal(refusal?.contract_slice, "resources");
});
test("a withheld chat.db refuses as capability_not_granted, naming the pane, in under 50 ms", () => {
// THE PROOF ON THIS MAC. The real database is right there (40 MB) and this
// process cannot open it, because macOS protects Messages behind Full Disk
// Access. That refusal IS the measured answer here.
const started = process.hrtime.bigint();
const refusal = fullDiskAccessRefusal();
const elapsedMs = Number(process.hrtime.bigint() - started) / 1e6;
// A grant that IS in place answers null, and this assertion is then vacuous
// by design — the point is that it never takes 50 ms and never throws.
assert.ok(elapsedMs < 50, `refusal took ${elapsedMs} ms`);
if (refusal !== null) {
assert.equal(refusal.outcome, "refused");
assert.equal(refusal.code, "capability_not_granted");
assert.match(refusal.message, /System Settings → Privacy & Security → Full Disk Access/);
assert.equal(refusal.contract_slice, "capabilities");
}
});
test("the refusal codes the contract declares are the codes the reader can answer", () => {
// ONE CLOSED TABLE. A code the code can produce but the contract does not
// declare is a refusal no caller can prepare for; the reverse is a promise
// nothing keeps.
const declared = Object.keys(HAND_CONTRACT.refusals);
// THE READER'S FOUR CONDITIONS, said with the COLLECTION's words ⟨lane
// refusals-2, 2026-09-09⟩: a macOS grant is capability_not_granted, a Mac
// with no chat.db and a runtime with no node:sqlite are both
// service_unavailable, and a locked database is precondition_failed.
for (const code of ["capability_not_granted", "service_unavailable", "precondition_failed"]) {
assert.ok(declared.includes(code), `${code} is answerable but undeclared`);
}
// The CLI's own conditions, declared so a caller reads one table.
for (const code of ["invalid_argument", "missing_argument", "unknown_verb"]) {
assert.ok(declared.includes(code));
}
for (const [code, entry] of Object.entries(HAND_CONTRACT.refusals)) {
assert.ok(entry.contract_slice.length > 0, `${code} names no contract slice`);
assert.ok(entry.fix.length > 0, `${code} names no fix`);
}
});
test("a database that is not a database is a refusal or a throw, never silent rows", () => {
const opened = openChatDb("/etc/hosts");
// Either it refuses by name, or sqlite rejects the file — what it must never
// do is answer an empty conversation, which reads as "no messages".
if ("refusal" in opened) assert.equal(opened.refusal.outcome, "refused");
else opened.db.close();
});
/* ── THE GRAMMAR ──────────────────────────────────────────────────────────── */
test("recent's first call carries no rule-59 hazard", async () => {
assert.equal(await exampleHazard("snappy-imessage", "recent"), null);
});
test("--limit parses to the count, with and without a contact", () => {
assert.deepEqual(parseRecentMessagesArgs(["--limit", "2"]), { contact: undefined, limit: 2 });
assert.deepEqual(parseRecentMessagesArgs([MARA, "--limit", "2"]), { contact: MARA, limit: 2 });
assert.equal(parseRecentMessagesArgs([]).limit, LIMIT.default);
});
test("a bare count is refused by name instead of becoming a contact", () => {
const parsed = parseRecentMessagesArgs([MARA, "5"]);
assert.match(parsed.refusal ?? "", /the count is a flag: recent \+15550100 --limit 5/);
});
test("the contract declares limit as a flag with a default and a ceiling", () => {
assert.deepEqual([...HAND_CONTRACT.verbs.recent.args], ["contact?"]);
assert.equal(HAND_CONTRACT.verbs.recent.flags.limit, "--limit");
assert.equal(HAND_CONTRACT.verbs.recent.inputSchema.properties.limit.default, LIMIT.default);
assert.equal(HAND_CONTRACT.verbs.recent.inputSchema.properties.limit.maximum, LIMIT.max);
// FULL DISK ACCESS IS NOT A CREDENTIAL, so it is never a `requires` entry —
// there is no key to add to .env.cache that would satisfy it.
assert.deepEqual([...HAND_CONTRACT.requires], []);
assert.ok("macos-full-disk-access" in HAND_CONTRACT.capabilities);
});
test("a faceless read stays faceless rather than borrowing the wrong face", () => {
const thread = rows(readRecent(MARA, 10, DB));
// snappy-faces wires no imessage LIST face, and several people's messages in
// one thread would draw as one conversation — a lie the reader cannot see.
assert.equal(imessageFaceForVerb("recent", undefined, thread), null);
assert.equal(imessageFaceForVerb("send", MARA, thread), null);
assert.equal(imessageFaceForVerb("thread", MARA, thread)?.kind, "imessage-thread");
});
/**
* THE READER IS PROVED AGAINST MESSAGES' OWN SCHEMA.
*
* Every test below runs the real SQL in `chat-db.ts` against a real SQLite
* database built by `fixtures/build-chat-db.ts` — the same tables, columns and
* joins Messages writes, with nobody real in it. That is the only honest way to
* test a reader: a mocked row proves the mock.
*
* NOTHING HERE TOUCHES THE OWNER'S MESSAGES. The fixture is generated into a
* temp directory at test time and thrown away; the real chat.db is never opened
* (measured 2026-09-09: this runner cannot open it at all — Full Disk Access is
* denied — which is what the refusal tests assert).
*/
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
HAND_CONTRACT, imessageFaceForVerb, parseRecentMessagesArgs,
} from "./api.ts";
import {
LIMIT, appleDateToIso, attachmentsFor, clampLimit, decodeAttributedBody, fullDiskAccessRefusal,
openChatDb, readContacts, readRecent, searchMessages, tapbackEmoji, tapbackTargetGuid,
} from "./chat-db.ts";
import { DEV, MARA, buildFixtureChatDb, typedStream } from "./fixtures/build-chat-db.ts";
import { exampleHazard } from "../snappy-tool-design/api.ts";
const DB = buildFixtureChatDb(join(mkdtempSync(join(tmpdir(), "snappy-imessage-")), "chat.db"));
/** Every read answers `{rows}` or `{refusal}`; a test that reached for `.rows`
* on a refusal would read `undefined` and pass vacuously, which is the exact
* failure CLAUDE.md calls "a refusal reported as an acceptance". */
function rows<T>(answer: { rows: T } | { refusal: unknown }): T {
assert.ok("rows" in answer, `expected rows, got ${JSON.stringify(answer)}`);
return answer.rows;
}
/* ── THE READER, INSIDE THE SKILL ─────────────────────────────────────────── */
test("a thread reads out of chat.db with no file outside these three", () => {
const thread = rows(readRecent(MARA, 10, DB));
// Oldest first, which is how a conversation reads.
assert.deepEqual(thread.map((row) => row.id), ["guid-8", "guid-1", "guid-2", "guid-3", "guid-4"]);
assert.equal(thread[1].text, "Are you still coming by the workshop?");
assert.equal(thread[1].from, MARA);
assert.equal(thread[1].outgoing, false);
// The second correspondent's message is NOT in this thread.
assert.ok(!thread.some((row) => row.text === "Deploy is green."));
});
test("the words come out of attributedBody when the text column is null", () => {
const thread = rows(readRecent(MARA, 10, DB));
const blobRow = thread.find((row) => row.id === "guid-3");
// THIS IS WHY THE PYTHON READER EXISTED. Modern iMessage keeps the message in
// a typedstream archive, and a reader that only selects `text` answers null
// for most of a real conversation.
assert.match(blobRow?.text ?? "", /^No rush at all — the glue is still curing/);
});
test("the decoder answers null rather than garbage when it cannot read a blob", () => {
assert.equal(decodeAttributedBody(null), null);
assert.equal(decodeAttributedBody(Buffer.alloc(0)), null);
assert.equal(decodeAttributedBody(Buffer.from("no class name here", "utf8")), null);
// A declared length running off the end of the buffer is refused, not read.
assert.equal(decodeAttributedBody(Buffer.from("NSString\x01\x94\x84\x01+\x40short", "latin1")), null);
// Short and long strings both round-trip; the long one takes the 0x81 branch.
assert.equal(decodeAttributedBody(typedStream("hello")), "hello");
const long = "x".repeat(400);
assert.equal(decodeAttributedBody(typedStream(long)), long);
assert.equal(decodeAttributedBody(typedStream("émoji 🚀 unicode")), "émoji 🚀 unicode");
});
test("both of Apple's clocks are read, so an archive is not dated to 2001", () => {
// Nanoseconds since 2001-01-01 (macOS 10.13 and later).
assert.equal(appleDateToIso("810228120000000000"), "2026-09-04T15:22:00.000Z");
// Seconds since 2001-01-01, the older encoding a carried-over database holds.
assert.equal(appleDateToIso("478602900"), "2016-03-02T09:15:00.000Z");
// Zero is "never" (an unset date_read), not the year 2001.
assert.equal(appleDateToIso(0), null);
assert.equal(appleDateToIso(null), null);
const archive = rows(readRecent(MARA, 10, DB)).find((row) => row.id === "guid-8");
assert.equal(archive?.date, "2016-03-02T09:15:00.000Z");
});
test("the blue-green bubble and the receipt ladder are read, not left null", () => {
const thread = rows(readRecent(MARA, 10, DB));
const byId = new Map(thread.map((row) => [row.id, row]));
// The retired reader printed three columns, so these three were ALWAYS null
// and every conversation drew flat.
assert.equal(byId.get("guid-1")?.service, "sms");
assert.equal(byId.get("guid-2")?.service, "imessage");
assert.equal(byId.get("guid-2")?.receipt, "read");
assert.equal(byId.get("guid-4")?.receipt, "delivered");
// An INCOMING message carries no receipt: nothing in this database says
// whether they read ours except our own copy.
assert.equal(byId.get("guid-1")?.receipt, null);
});
test("a tapback folds onto its message, and its removal cancels it", () => {
const byId = new Map(rows(readRecent(MARA, 10, DB)).map((row) => [row.id, row]));
assert.equal(byId.get("guid-4")?.reaction, "❤️");
// guid-1 was liked and then un-liked; a reader that only reads 2000-2005
// leaves a thumbs-up on a message the person took it off.
assert.equal(byId.get("guid-1")?.reaction, null);
// And a tapback is never a bubble of its own.
assert.ok(!byId.has("guid-5"));
assert.ok(!byId.has("guid-9"));
assert.equal(tapbackEmoji(2000), "❤️");
assert.equal(tapbackEmoji(3000), null);
assert.equal(tapbackEmoji(0), null);
assert.equal(tapbackTargetGuid("p:0/guid-4"), "guid-4");
assert.equal(tapbackTargetGuid("bp:guid-4"), "guid-4");
assert.equal(tapbackTargetGuid(null), null);
});
test("the limit counts MESSAGES, never the tapback rows beside them", () => {
// MEASURED 2026-09-09 before this fix: `--limit 3` answered ONE message,
// because two of the three newest rows were a tapback and its removal.
assert.equal(rows(readRecent(MARA, 3, DB)).length, 3);
assert.equal(rows(readRecent(undefined, 2, DB)).length, 2);
// The ask is clamped rather than obeyed without bound, and a bad ask falls
// to the declared default instead of answering nothing.
assert.equal(clampLimit(10_000), LIMIT.max);
assert.equal(clampLimit(0), LIMIT.default);
assert.equal(clampLimit(undefined), LIMIT.default);
});
test("search reaches the words that live in a blob, and says how far it looked", () => {
// "glue" is ONLY in the attributedBody row, so a SQL-only search finds
// nothing — which is what this verb did before the second pass.
const found = rows(searchMessages("glue", 5, DB));
assert.equal(found.rows.length, 1);
assert.equal(found.rows[0].id, "guid-3");
assert.equal(found.truncated, false);
// The plain column still matches, case-insensitively.
assert.equal(rows(searchMessages("DEPLOY", 5, DB)).rows[0]?.id, "guid-7");
// A phrase nobody wrote answers an empty list, which is an ANSWER.
assert.equal(rows(searchMessages("zzz-nobody-wrote-this", 5, DB)).rows.length, 0);
});
test("contacts answers handles Messages itself holds, and counts only messages", () => {
const people = rows(readContacts(10, DB));
assert.deepEqual(people.map((person) => person.handle), [DEV, MARA]);
const mara = people.find((person) => person.handle === MARA);
assert.equal(mara?.chat, "Mara Quill");
// Five messages, not seven: the two tapback rows are not messages.
assert.equal(mara?.messages, 5);
assert.equal(mara?.last, "2026-09-04T15:20:00.000Z");
});
test("attachments answers paths only and never opens a file", () => {
const files = rows(attachmentsFor(MARA, 10, DB));
assert.equal(files.length, 1);
assert.equal(files[0].name, "cut-list.pdf");
assert.equal(files[0].mime, "application/pdf");
assert.equal(files[0].bytes, 51_234);
assert.equal(files[0].outgoing, true);
});
/* ── THE REFUSALS, EVERY CODE IN THE CONTRACT'S CLOSED TABLE ──────────────── */
test("a Mac with no Messages database refuses by name: service_unavailable", () => {
const refusal = fullDiskAccessRefusal("/nowhere/at/all/chat.db");
assert.equal(refusal?.outcome, "refused");
assert.equal(refusal?.code, "service_unavailable");
// The MESSAGE is the hand's own sentence about THIS failure; `fix` comes
// from the one table, so the machine-readable next step can never drift.
assert.match(refusal?.message ?? "", /Mac that is signed in to Messages/i);
assert.equal(refusal?.contract_slice, "resources");
});
test("a withheld chat.db refuses as capability_not_granted, naming the pane, in under 50 ms", () => {
// THE PROOF ON THIS MAC. The real database is right there (40 MB) and this
// process cannot open it, because macOS protects Messages behind Full Disk
// Access. That refusal IS the measured answer here.
const started = process.hrtime.bigint();
const refusal = fullDiskAccessRefusal();
const elapsedMs = Number(process.hrtime.bigint() - started) / 1e6;
// A grant that IS in place answers null, and this assertion is then vacuous
// by design — the point is that it never takes 50 ms and never throws.
assert.ok(elapsedMs < 50, `refusal took ${elapsedMs} ms`);
if (refusal !== null) {
assert.equal(refusal.outcome, "refused");
assert.equal(refusal.code, "capability_not_granted");
assert.match(refusal.message, /System Settings → Privacy & Security → Full Disk Access/);
assert.equal(refusal.contract_slice, "capabilities");
}
});
test("the refusal codes the contract declares are the codes the reader can answer", () => {
// ONE CLOSED TABLE. A code the code can produce but the contract does not
// declare is a refusal no caller can prepare for; the reverse is a promise
// nothing keeps.
const declared = Object.keys(HAND_CONTRACT.refusals);
// THE READER'S FOUR CONDITIONS, said with the COLLECTION's words ⟨lane
// refusals-2, 2026-09-09⟩: a macOS grant is capability_not_granted, a Mac
// with no chat.db and a runtime with no node:sqlite are both
// service_unavailable, and a locked database is precondition_failed.
for (const code of ["capability_not_granted", "service_unavailable", "precondition_failed"]) {
assert.ok(declared.includes(code), `${code} is answerable but undeclared`);
}
// The CLI's own conditions, declared so a caller reads one table.
for (const code of ["invalid_argument", "missing_argument", "unknown_verb"]) {
assert.ok(declared.includes(code));
}
for (const [code, entry] of Object.entries(HAND_CONTRACT.refusals)) {
assert.ok(entry.contract_slice.length > 0, `${code} names no contract slice`);
assert.ok(entry.fix.length > 0, `${code} names no fix`);
}
});
test("a database that is not a database is a refusal or a throw, never silent rows", () => {
const opened = openChatDb("/etc/hosts");
// Either it refuses by name, or sqlite rejects the file — what it must never
// do is answer an empty conversation, which reads as "no messages".
if ("refusal" in opened) assert.equal(opened.refusal.outcome, "refused");
else opened.db.close();
});
/* ── THE GRAMMAR ──────────────────────────────────────────────────────────── */
test("recent's first call carries no rule-59 hazard", async () => {
assert.equal(await exampleHazard("snappy-imessage", "recent"), null);
});
test("--limit parses to the count, with and without a contact", () => {
assert.deepEqual(parseRecentMessagesArgs(["--limit", "2"]), { contact: undefined, limit: 2 });
assert.deepEqual(parseRecentMessagesArgs([MARA, "--limit", "2"]), { contact: MARA, limit: 2 });
assert.equal(parseRecentMessagesArgs([]).limit, LIMIT.default);
});
test("a bare count is refused by name instead of becoming a contact", () => {
const parsed = parseRecentMessagesArgs([MARA, "5"]);
assert.match(parsed.refusal ?? "", /the count is a flag: recent \+15550100 --limit 5/);
});
test("the contract declares limit as a flag with a default and a ceiling", () => {
assert.deepEqual([...HAND_CONTRACT.verbs.recent.args], ["contact?"]);
assert.equal(HAND_CONTRACT.verbs.recent.flags.limit, "--limit");
assert.equal(HAND_CONTRACT.verbs.recent.inputSchema.properties.limit.default, LIMIT.default);
assert.equal(HAND_CONTRACT.verbs.recent.inputSchema.properties.limit.maximum, LIMIT.max);
// FULL DISK ACCESS IS NOT A CREDENTIAL, so it is never a `requires` entry —
// there is no key to add to .env.cache that would satisfy it.
assert.deepEqual([...HAND_CONTRACT.requires], []);
assert.ok("macos-full-disk-access" in HAND_CONTRACT.capabilities);
});
test("a faceless read stays faceless rather than borrowing the wrong face", () => {
const thread = rows(readRecent(MARA, 10, DB));
// snappy-faces wires no imessage LIST face, and several people's messages in
// one thread would draw as one conversation — a lie the reader cannot see.
assert.equal(imessageFaceForVerb("recent", undefined, thread), null);
assert.equal(imessageFaceForVerb("send", MARA, thread), null);
assert.equal(imessageFaceForVerb("thread", MARA, thread)?.kind, "imessage-thread");
});
#!/usr/bin/env npx tsx
/**
* snappy-imessage/api.ts -- iMessage on THIS Mac, in three files.
*
* ONE COMPUTER (D5, 2026-09-06). This hand runs osascript and reads chat.db on
* whichever Mac is holding Messages.app -- the one it is executing on. There is
* no hostname test, no `mini` alias and no SSH branch: a hand that tries to
* reach "the other machine" is a hand that cannot run where the founder is.
*
* THE READER IS INSIDE THE SKILL (2026-09-09). It used to shell out to
* `~/.snappy/imessage-read.py`, a file this skill does not ship and which was
* MEASURED ABSENT on this Mac -- so every read refused on the one machine that
* holds the database. It is now `chat-db.ts` over node's own `node:sqlite`.
* Nothing outside these files is required to read a message.
*
* FULL DISK ACCESS IS THE REQUIREMENT, and it is a machine capability rather
* than a credential -- there is no key for it, so it can never appear in
* `requires`. A denied read refuses in well under a millisecond, naming the
* System Settings pane. See `chat-db.ts`.
*
* A SEND IS STAGED, EXCEPT TO THE OWNER HIMSELF. See the `send` arm below.
*
* Usage:
* npx tsx api.ts recent --limit 5 --json
* npx tsx api.ts thread +14155551212 --json
* npx tsx api.ts search "invoice" --limit 20 --json
* npx tsx api.ts contacts --json
* npx tsx api.ts attachments +14155551212 --json
* npx tsx api.ts send +14155551212 "Hey!" # stages for a decision
* npx tsx api.ts send +14155551212 "Hey!" --now # a human already decided
*
* Or import as module:
* import { sendIMessage, readRecent } from "../snappy-imessage/api.ts";
*/
import { execSync } from "child_process";
import { realpathSync } from "fs";
import { env } from "../snappy-settings/load.ts";
import { stageHandOperation } from "../snappy-settings/stage.ts";
import { decisionInContext, standingDoors, type DecisionInContext } from "../hand-decision-face.ts";
import {
LIMIT, attachmentsFor, chatDbPath, fullDiskAccessRefusal, readContacts, readRecent as readMessages,
searchMessages,
} from "./chat-db.ts";
import type { ChatDbRefusal, IMessageAttachment, IMessageContact, IMessageRow } from "./chat-db.ts";
import { printRefusal, refuseCli, refusalTable } from "../snappy-settings/refusal-codes.ts";
import { pinnedFile } from "../snappy-settings/hand-resources.ts";
export type { ChatDbRefusal, IMessageAttachment, IMessageContact, IMessageRow };
export { LIMIT, chatDbPath, fullDiskAccessRefusal };
const CONTRACT_DESCRIPTION =
"Reads and sends iMessage and SMS on the Mac signed in to Messages, straight out of chat.db with node's own SQLite: recent messages, one person's thread, a phrase search, the handles seen, and attachment paths. A send to anyone but the owner stages for his decision. Needs macOS Full Disk Access rather than a credential, unlike snappy-whatsapp and snappy-telegram. Use when asked what someone texted, to search the message archive, or to text a phone number. Triggers on: imessage, text message, sms, Messages app, chat.db, text someone, what did they text.";
/** THE TYPED CONTRACT OF THIS HAND. What each verb takes, in order, and what it
* does to the world. Snappy's daemon reads it (`api.ts contract`) to validate
* an MCP call or an OpenUI button, build the argument words, run reversible
* verbs directly and stage the rest. It is the one representation of this
* hand's grammar -- the usage lines above must agree with it. */
export const HAND_CONTRACT = {
skill: "snappy-imessage",
description: CONTRACT_DESCRIPTION,
kind: "tool",
/** ⟨ORG-R6, 2026-09-06⟩ Snappy's own credential store holds this login, so
* the account a receipt names is one this product can rotate and pin. */
managed: true,
/** THE KEYS THIS HAND ASKS FOR, BY NAME -- never their values. Measured from
* the `env(...)` reads in this file: `ownerPhone()` is the only one, and it
* is OPTIONAL by design (absent, every send stages, which is the safe
* direction). Full Disk Access is NOT here because it is not a key; see
* `capabilities` below. */
requires: [] as string[],
/** WHAT THIS HAND NEEDS THAT IS NEITHER A CREDENTIAL NOR A GRANT ⟨lane
* mini-reads, 2026-09-09⟩. `fullDiskAccessRefusal()` refuses
* `service_unavailable` when chat.db is simply not on this Mac — a
* condition no key and no TCC pane fixes — and nothing in the contract said
* so, so a picker could pick a read that cannot answer here. Declared, a
* census can check the file exists before choosing this hand. */
resources: {
"messages-database": pinnedFile({
id: "messages-database",
name: "the Messages database (chat.db)",
path: "~/Library/Messages/chat.db",
why: "every read verb walks it; a Mac not signed in to Messages has none",
}),
},
/** WHAT THIS MAC MUST GRANT, which is a different thing from a credential.
* Neither is fixable by editing `.env.cache`, and both refuse by name. */
capabilities: {
"macos-full-disk-access": "Reads ~/Library/Messages/chat.db. Every read verb refuses without it, naming the System Settings pane.",
"macos-automation-messages": "Sends through Messages.app by AppleEvent. Only `send` needs it.",
},
/** THE CLOSED TABLE OF WAYS THIS HAND SAYS NO. Each names the slice of this
* contract that was violated and the move that fixes it, so a refusal is
* actionable rather than a stack trace. */
/** A PROJECTION OF THE ONE TABLE, never a second one ⟨lane refusals-2,
* 2026-09-09⟩. These eight rows used to be written out here in this hand's
* own words — `no_full_disk_access`, `chat_db_locked`, `bad_phone_number` —
* which is the drift CLAUDE.md §4 bans: a caller who had enumerated the
* collection's refusal words could not branch on any of them. The
* conditions are unchanged; each is now said with the collection's word.
* A macOS grant is `capability_not_granted` (a TCC pane, not a key and not
* a provider scope); a Mac with no Messages database and a runtime with no
* node:sqlite are both `service_unavailable`; a locked chat.db is
* `precondition_failed`; a phone number that is not E.164 is
* `invalid_argument`. */
refusals: refusalTable(
"capability_not_granted",
"service_unavailable",
"precondition_failed",
"invalid_argument",
"missing_argument",
"unknown_verb",
),
verbs: {
// THE COUNT IS A FLAG, NOT THE SECOND WORD (R59, measured 2026-09-08).
// `limit` used to be the second positional, so the next thing an AI wrote
// after the first call -- `recent 5` -- filed the 5 as a CONTACT and
// answered the last 10 messages of a person who does not exist.
recent: {
args: ["contact?"], effect: "read", class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
flags: { limit: "--limit", json: "--json" },
inputSchema: { properties: {
contact: { type: "string", description: "Optional phone in E.164, Apple ID email, or part of a conversation's name; omit for the newest messages across every conversation" },
limit: { type: "integer", description: "How many messages to answer, newest first", default: LIMIT.default, maximum: LIMIT.max },
} },
},
/** `thread` IS `recent <contact>`, SPELLED AS THE SHAPE. The face join
* derives a face from the verb's own word, and "recent" folds onto `list`
* -- a shape the imessage family declares NO face for, and the wrong one
* anyway: the messages with one person are a conversation, not a page of
* things. It takes the contact as a REQUIRED word, because a thread with
* nobody in it is several people's messages in one conversation, which is
* a lie the reader cannot see through. */
thread: {
args: ["contact"], effect: "read", class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
flags: { limit: "--limit", json: "--json" },
inputSchema: { properties: {
contact: { type: "string", description: "The person: a phone in E.164, an Apple ID email, or part of the conversation's name" },
limit: { type: "integer", description: "How many of that conversation's messages to answer, newest first", default: LIMIT.default, maximum: LIMIT.max },
} },
},
search: {
args: ["text"], effect: "read", class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
flags: { limit: "--limit", json: "--json" },
inputSchema: { properties: {
text: { type: "string", description: "The phrase to find in message bodies, matched case-insensitively" },
limit: { type: "integer", description: "How many matches to answer, newest first", default: LIMIT.default, maximum: LIMIT.max },
} },
},
contacts: {
args: [] as string[], effect: "read", class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
flags: { limit: "--limit", json: "--json" },
inputSchema: { properties: {
limit: { type: "integer", description: "How many correspondents to answer, most recently active first", default: LIMIT.default, maximum: LIMIT.max },
} },
},
attachments: {
args: ["contact"], effect: "read", class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
flags: { limit: "--limit", json: "--json" },
inputSchema: { properties: {
contact: { type: "string", description: "Whose conversation's files to list: a phone in E.164, an Apple ID email, or part of the conversation's name" },
limit: { type: "integer", description: "How many files to answer, newest first", default: LIMIT.default, maximum: LIMIT.max },
} },
},
// A send names its TARGET so the staged row can say who it reaches. There
// is deliberately no separate `stage` verb: `send` without `--now` IS the
// stage, so a caller cannot pick the arm that skips the decision.
send: {
args: ["phone", "text"], effect: "send", class: "send-to-a-person", execution: "call",
openWorld: true, target: "phone",
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
// `--now` IS NOT DECLARED HERE, and that is the collection's convention
// (snappy-telegram's `send` declares none either). It is the STAGE DOOR's
// execution word -- what the owner's decision appends when it runs this
// same verb -- not part of this hand's grammar. Declaring it would put a
// mode switch on a write verb and invite a caller to reach for the arm
// that skips the decision.
/** `--json` ON A WRITE VERB IS A PREVIEW ⟨the owner's shape law, 2026-09-09
* 01:5x⟩, never a second output format: it prints the message inside the
* conversation it joins and touches nothing. Undeclared, the daemon
* refuses the flag at the door and the preview is unreachable. */
flags: { json: "--json" },
inputSchema: { properties: {
phone: { type: "string", description: "Who receives it, in E.164, for example +14155551212" },
text: { type: "string", description: "The message body, sent exactly as written" },
} },
},
},
} as const;
/** IS THIS FILE THE COMMAND, or is something importing it? `realpathSync`
* because skills are symlinked from `~/.claude/skills` into the kernel and
* `process.argv[1]` is the symlink path (the non-realpath form silently never
* fires; 2026-09-02). The argv[1] guard is 2026-09-09: under `node -e` and
* under some runners argv[1] is UNDEFINED, and `realpathSync(undefined)` threw
* ENOENT at import time — so merely importing this hand from another module
* crashed the caller before a single verb ran. */
function isDirectRun(): boolean {
const entry = process.argv[1];
if (entry === undefined || entry === "") return false;
try { return import.meta.url === `file://${realpathSync(entry)}`; } catch { return false; }
}
if (isDirectRun() && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
/** THE OWNER'S OWN NUMBER -- his phone, in E.164, from the machine's own env.
* `SNAPPY_OWNER_PHONE` is the agnostic spelling; `ROBERT_PHONE` is the one
* this cache already holds. Absent means the rule cannot fire and EVERY send
* stages, which is the safe direction: an unset key must never widen what
* sends without a decision. That is also why it is not in `requires` -- a
* requirement would refuse the read verbs over a key only `send` consults. */
function ownerPhone(): string | null {
for (const key of ["SNAPPY_OWNER_PHONE", "ROBERT_PHONE"]) {
try { const value = env(key, false)?.trim(); if (value) return value; } catch { /* unset */ }
}
return null;
}
/** THE ONE SENTENCE THE OWNER CAN ACT ON for a send. Messages automation is a
* macOS Automation grant. Denied, the AppleEvent hangs (the prompt nobody
* sees) or answers -1743 / -1712; a raw ETIMEDOUT told the agent nothing. */
const AUTOMATION_SENTENCE = "Messages automation is not allowed for this app on this Mac: System Settings → Privacy & Security → Automation → allow Messages for the app that runs this, then run this again.";
function local(command: string): string {
try {
return execSync(command, { encoding: "utf-8", timeout: 20_000, stdio: ["ignore", "pipe", "pipe"] }).trim();
} catch (error) {
const text = error instanceof Error ? `${error.message} ${(error as { stderr?: string }).stderr ?? ""}` : String(error);
if (/ETIMEDOUT|-1743|-1712|-600|not allowed|Not authorized/u.test(text)) throw new Error(AUTOMATION_SENTENCE);
throw error;
}
}
/** Send an iMessage to a phone number (E.164 format). */
export function sendIMessage(to: string, text: string): string {
if (!/^\+\d{10,15}$/.test(to)) {
throw new Error(`Invalid E.164 phone number: ${to}. Give it as +14155551212.`);
}
const escapedText = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return local(
`osascript -e 'tell application "Messages" to send "${escapedText}" to buddy "${to}" of (service 1 whose service type is iMessage)'`
);
}
/** THE GRAMMAR OF A READ, owned in ONE place so the CLI and its test read the
* same words. A leftover positional count REFUSES by name rather than being
* silently dropped: a dropped count answers the default and looks like a real
* answer, which is the failure this rule exists to end. */
export function parseRecentMessagesArgs(args: string[]): { contact?: string; limit: number; refusal?: string } {
const positional = args.filter((arg, index) => !arg.startsWith("--") && args[index - 1] !== "--limit");
const limitAt = args.indexOf("--limit");
const limit = limitAt >= 0 ? Number(args[limitAt + 1]) || LIMIT.default : LIMIT.default;
if (positional.length > 1) {
return { limit, refusal: `recent takes one contact; the count is a flag: recent ${positional[0]} --limit ${positional[1]}` };
}
return { contact: positional[0] || undefined, limit };
}
/* ── THE FACE THIS READ TAKES ────────────────────────────────────────────────
*
* `--json` PRINTS THE FACE'S OBJECT where an iMessage face draws the answer.
* Without `--json` the rows print as `date<TAB>sender<TAB>text`, which is what
* a shell pipeline and the channel adapter read.
*
* A CONTACTLESS `recent` IS NOT A THREAD, AND SAYS SO. With no contact the
* reader answers the last N messages ACROSS EVERY CHAT -- several people at
* once. Drawing that as one thread would put three people's words in one
* conversation with left/right bubbles implying a single correspondent, which
* is a lie the reader cannot see through. snappy-faces wires FOUR imessage
* kinds -- one, thread, compose, decision (faces-wired.ts, re-measured
* 2026-09-09) -- and NO list, so a contactless `recent --json` prints its own
* {count, messages} answer and NO kind: a face nobody built is not a face to
* fake, and `contacts` is the verb that answers "who has been texting".
*/
/** `thread <contact>` (and `recent <contact>`) → the `imessage-thread` face.
*
* EVERY FIELD THE FACE DECLARES IS NOW FILLED, which the retired python
* reader could not do: it printed three columns, so `service` (the blue/green
* bubble), `receipt` (Delivered / Read) and `reaction` (a tapback) were always
* null and the conversation drew flat. Reading chat.db directly, they come off
* the columns that hold them. */
export function imessageThreadFace(rows: IMessageRow[], chat: string | null = null): Record<string, unknown> {
return {
kind: "imessage-thread",
messages: rows.map((row) => ({
id: row.id,
// The face declares `text` as a REQUIRED string. A message whose whole
// content is an attachment has no words at all, and the honest stand-in
// is what Messages itself shows -- the file's name -- never "[no text]".
text: row.text ?? (row.attachments.length > 0 ? row.attachments.join(", ") : ""),
date: row.date ?? "",
outgoing: row.outgoing,
from: row.from,
service: row.service,
receipt: row.receipt,
reaction: row.reaction,
})),
chat: chat ?? rows.find((row) => row.chat !== null)?.chat ?? null,
total: rows.length,
};
}
/** THE ANSWER IN THE CONVERSATION IT ANSWERS ⟨the owner's shape law, 2026-09-09
* 01:5x: "for ANY message it should show the THREAD — WhatsApp, iMessage,
* Statechange, Gmail, comments, everything; you don't just show me the email
* you're going to send, you show it in the context"⟩.
*
* `thread` is the SAME rows `imessageThreadFace` prints — one representation
* ⟨CLAUDE.md §4⟩ — so the conversation a person reads before approving a text
* is the conversation `thread --json` would have drawn, service colours,
* receipts and tapbacks and all.
*
* THE BUBBLE'S COLOUR IS A CLAIM ABOUT WHICH WIRE IT GOES OUT ON, so `asSms`
* is carried only when the conversation's OWN last message says SMS. Guessing
* green would tell a person their words are leaving as a text message when
* Messages will send them as an iMessage, or the reverse — a field that looks
* filled and is wrong ⟨CLAUDE.md §10⟩.
*
* THE FDA REFUSAL IS NOT THIS FUNCTION'S PROBLEM AND MUST NOT BECOME ONE. A
* Mac that will not open chat.db has no conversation to show, and the honest
* answer there is the refusal the read already gives — never an empty thread,
* which would draw as "you have never spoken to this person". The CLI refuses
* before it reaches here. */
export function imessageDecisionFace(input: {
rows: IMessageRow[]; to: string; body: string; chat?: string | null; waitingWords?: string | null;
/** THE ACT A PRESS RUNS ⟨lane doors-everywhere, 2026-09-09⟩: this hand's own
* contract verb and `HAND_CONTRACT.verbs[verb].args` verbatim. Without it the
* door was a button nothing could build a press for. */
act: { verb: string; args: readonly string[]; values?: Record<string, unknown> };
}): DecisionInContext {
const drawn = imessageThreadFace(input.rows, input.chat ?? null);
const rows = drawn.messages as Record<string, unknown>[];
const who = (drawn.chat as string | null) ?? input.to;
// The conversation's own service, off its most recent message. `null` where
// the read carried none, and then nothing is claimed either way.
const service = [...input.rows].reverse().find((row) => row.service !== null)?.service ?? null;
return decisionInContext({
decisionKind: "imessage-decision",
composeKind: "imessage-compose",
threadKind: "imessage-thread",
thread: rows,
threadTotal: rows.length,
draft: {
to: who,
body: input.body,
...(service === "sms" ? { asSms: true } : {}),
...(rows.length > 0 ? { waitingWords: input.waitingWords ?? null } : {}),
// THE ACT'S OWN WORDS ⟨doors-everywhere⟩. `to` is the person's NAME when
// the conversation knows one, which is what Messages draws over a chat
// and is not something you can send to; `phone` is the contract's word
// and the number the act really takes.
phone: input.to,
text: input.body,
},
act: input.act,
doors: standingDoors(`sends the message to ${who} from this Mac now`),
});
}
/** THE ONE PLACE a verb's answer becomes its face. Null for a read no iMessage
* face draws -- see the contactless `recent` note above.
*
* NAMED FOR ITS HAND ⟨X1, 2026-09-09⟩: ten hands exported `faceForVerb` and
* the collection lint's one-exported-name rule failed all of them. The other
* eight stopped exporting theirs; this one is exported because `face.test.ts`
* asserts on it directly, so it carries its hand's name instead. */
export function imessageFaceForVerb(command: string, contact: string | undefined, rows: IMessageRow[]): Record<string, unknown> | null {
if (command !== "recent" && command !== "thread" && command !== "search") return null;
if (command !== "search" && contact === undefined) return null;
return imessageThreadFace(rows, command === "search" ? null : contact ?? null);
}
/** A refusal printed the one way — ON STDOUT, as one JSON object, exit 1. A
* read that cannot run must never look like a read that found nothing: an
* empty list is an answer, and this is not one.
*
* IT WAS ON STDERR ⟨measured 2026-09-09: `recent` printed the envelope to
* stderr and NOTHING to stdout⟩. stderr is where a runner puts noise it does
* not read; the refusal is the ANSWER to the call, so it goes where the
* answer goes. `printRefusal` from the one table owns that choice now, so it
* cannot be made differently in the next hand. */
function refuse(refusal: ChatDbRefusal): never {
printRefusal(refusal);
process.exit(1);
}
/** The rows, or the refusal, printed and exited. One place, so every read verb
* refuses identically. */
function rowsOrRefuse<T>(answer: { rows: T } | { refusal: ChatDbRefusal }): T {
if ("refusal" in answer) refuse(answer.refusal);
return answer.rows;
}
// --- CLI ---
if (isDirectRun()) {
(async () => {
const [, , cmd, ...args] = process.argv;
const json = args.includes("--json");
switch (cmd) {
case "send": {
// `--now` is the hand door's execution word, never part of the text;
// `--json` is the preview, and neither is ever a word of the message.
const [to, ...textParts] = args.filter((a) => a !== "--now" && a !== "--json");
if (!to || !textParts.length) { refuseCli("missing_argument", "send needs the phone number and the text; contract slice verbs.send.args = [to, text]. Usage: api.ts send <phone> <text> [--now] [--json]"); process.exit(1); }
const text = textParts.join(" ");
// A PREVIEW TOUCHES NOTHING ⟨the owner's shape law, 2026-09-09 01:5x⟩:
// nothing sent, nothing staged. The conversation is READ FIRST, because
// it is what the person approving reads — and a Mac that will not open
// chat.db REFUSES here exactly as every read verb does, naming the
// System Settings pane. An empty thread would draw as "you have never
// spoken to this person", which is a lie about the machine's
// permissions dressed as a fact about the conversation.
if (args.includes("--json")) {
const rows = rowsOrRefuse(readMessages(to, LIMIT.default));
console.log(JSON.stringify(imessageDecisionFace({ rows, to, body: text,
act: { verb: "send", args: HAND_CONTRACT.verbs.send.args } }), null, 2));
break;
}
// THE OWNER HIMSELF IS NOT "anyone" (employee model, 2026-09-06). A
// text to the owner's own phone is the owner being told what he asked
// to be told -- it sends, and the receipt is the proof. A text to ANY
// OTHER number is a send to a person: it goes through the stage door
// and the decision runs this exact verb with `--now`. An unset owner
// number stages everything, because a missing key must never widen
// what may send.
const owner = ownerPhone();
if (to !== owner && !args.includes("--now")) {
const staged = await stageHandOperation({
skill: "snappy-imessage", verb: "send", argv: ["{{phone}}", "{{text}}"],
fields: { phone: to, text, title: `iMessage to ${to}`, body: text },
target: "imessage", facet: "chat-message",
action_label: `Send iMessage to ${to}`, reversible: false, risk: "medium",
});
if (staged.staged) { console.log(`staged for approval: control ${staged.control_id} (the decision sends it)`); break; }
console.error(`not staged: ${JSON.stringify(staged.answer).slice(0, 300)}`); process.exit(1);
}
console.log(sendIMessage(to, text));
console.log(`sent to ${to === owner ? "the owner's own phone" : to}`);
break;
}
case "recent": case "thread": {
const { contact, limit, refusal } = parseRecentMessagesArgs(args);
if (refusal) { refuseCli("invalid_argument", refusal); process.exit(1); }
if (cmd === "thread" && !contact) { refuseCli("missing_argument", "thread needs the contact whose messages to read; contract slice verbs.thread.args = [contact]. Usage: api.ts thread <contact> [--limit N] [--json]"); process.exit(1); }
const rows = rowsOrRefuse(readMessages(contact, limit));
if (!json) { printRows(rows); break; }
const face = imessageFaceForVerb(cmd, contact, rows);
console.log(JSON.stringify(face ?? { count: rows.length, messages: rows }, null, 2));
break;
}
case "search": {
const { contact: phrase, limit, refusal } = parseRecentMessagesArgs(args);
if (refusal) { refuseCli("invalid_argument", refusal); process.exit(1); }
if (!phrase) { refuseCli("missing_argument", "search needs the text to look for; contract slice verbs.search.args = [text]. Usage: api.ts search <text> [--limit N] [--json]"); process.exit(1); }
const found = rowsOrRefuse(searchMessages(phrase, limit));
if (!json) {
printRows(found.rows);
if (found.truncated) console.error(`(searched the newest ${found.scanned} messages whose text is stored as an archive; older ones were not decoded)`);
break;
}
console.log(JSON.stringify({ ...imessageThreadFace(found.rows), scanned: found.scanned, truncated: found.truncated }, null, 2));
break;
}
case "contacts": {
const { limit } = parseRecentMessagesArgs(args);
const rows = rowsOrRefuse(readContacts(limit));
if (json) { console.log(JSON.stringify({ count: rows.length, contacts: rows }, null, 2)); break; }
for (const row of rows) console.log(`${row.last ?? "?"}\t${row.handle}\t${row.chat ?? ""}\t${row.messages}`);
break;
}
case "attachments": {
const { contact, limit } = parseRecentMessagesArgs(args);
if (!contact) { refuseCli("missing_argument", "attachments needs the contact whose files to list; contract slice verbs.attachments.args = [contact]. Usage: api.ts attachments <contact> [--limit N] [--json]"); process.exit(1); }
const rows = rowsOrRefuse(attachmentsFor(contact, limit));
if (json) { console.log(JSON.stringify({ count: rows.length, attachments: rows }, null, 2)); break; }
for (const row of rows) console.log(`${row.date ?? "?"}\t${row.name}\t${row.path ?? ""}`);
break;
}
case "contract": { console.log(JSON.stringify(HAND_CONTRACT, null, 2)); break; }
// A WORD OUTSIDE THE CONTRACT IS A REFUSAL, NOT A USAGE LINE ⟨lane
// refusals-2, 2026-09-09⟩. This arm printed the usage to stdout and
// exited 0, so a caller that asked for a verb this hand does not have
// was told the call SUCCEEDED. Asking for help still gets help.
default: {
const usage = "Usage: npx tsx api.ts [recent|thread|search|contacts|attachments|send] ... [--limit N] [--json]\n--json on thread/search prints the iMessage face's own object (imessage-thread).\n--json on send PREVIEWS the decision in its context ({kind, thread, draft, doors}) and touches nothing.";
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") { console.log(usage); break; }
refuseCli("unknown_verb", `unknown verb "${cmd}"; contract slice verbs = [${Object.keys(HAND_CONTRACT.verbs).join(", ")}]`);
break;
}
}
})();
}
/** The plain-text rendition, which is what a shell pipeline and `adapter.ts`
* read. One tab-separated line per message, the reader's own words. */
function printRows(rows: IMessageRow[]): void {
for (const row of rows) {
console.log(`${row.date ?? "?"}\t${row.outgoing ? "me" : row.from ?? "unknown"}\t${(row.text ?? "").slice(0, 500)}`);
}
}
/** Read the newest messages, as a module. Kept as the exported name callers
* already spell (`adapter.ts` and the channel road). */
export function readRecent(contact?: string, limit = LIMIT.default): IMessageRow[] {
const answer = readMessages(contact, limit);
if ("refusal" in answer) throw new Error(`${answer.refusal.message} ${answer.refusal.fix}`);
return answer.rows;
}
#!/usr/bin/env npx tsx
/**
* snappy-imessage/api.ts -- iMessage on THIS Mac, in three files.
*
* ONE COMPUTER (D5, 2026-09-06). This hand runs osascript and reads chat.db on
* whichever Mac is holding Messages.app -- the one it is executing on. There is
* no hostname test, no `mini` alias and no SSH branch: a hand that tries to
* reach "the other machine" is a hand that cannot run where the founder is.
*
* THE READER IS INSIDE THE SKILL (2026-09-09). It used to shell out to
* `~/.snappy/imessage-read.py`, a file this skill does not ship and which was
* MEASURED ABSENT on this Mac -- so every read refused on the one machine that
* holds the database. It is now `chat-db.ts` over node's own `node:sqlite`.
* Nothing outside these files is required to read a message.
*
* FULL DISK ACCESS IS THE REQUIREMENT, and it is a machine capability rather
* than a credential -- there is no key for it, so it can never appear in
* `requires`. A denied read refuses in well under a millisecond, naming the
* System Settings pane. See `chat-db.ts`.
*
* A SEND IS STAGED, EXCEPT TO THE OWNER HIMSELF. See the `send` arm below.
*
* Usage:
* npx tsx api.ts recent --limit 5 --json
* npx tsx api.ts thread +14155551212 --json
* npx tsx api.ts search "invoice" --limit 20 --json
* npx tsx api.ts contacts --json
* npx tsx api.ts attachments +14155551212 --json
* npx tsx api.ts send +14155551212 "Hey!" # stages for a decision
* npx tsx api.ts send +14155551212 "Hey!" --now # a human already decided
*
* Or import as module:
* import { sendIMessage, readRecent } from "../snappy-imessage/api.ts";
*/
import { execSync } from "child_process";
import { realpathSync } from "fs";
import { env } from "../snappy-settings/load.ts";
import { stageHandOperation } from "../snappy-settings/stage.ts";
import { decisionInContext, standingDoors, type DecisionInContext } from "../hand-decision-face.ts";
import {
LIMIT, attachmentsFor, chatDbPath, fullDiskAccessRefusal, readContacts, readRecent as readMessages,
searchMessages,
} from "./chat-db.ts";
import type { ChatDbRefusal, IMessageAttachment, IMessageContact, IMessageRow } from "./chat-db.ts";
import { printRefusal, refuseCli, refusalTable } from "../snappy-settings/refusal-codes.ts";
import { pinnedFile } from "../snappy-settings/hand-resources.ts";
export type { ChatDbRefusal, IMessageAttachment, IMessageContact, IMessageRow };
export { LIMIT, chatDbPath, fullDiskAccessRefusal };
const CONTRACT_DESCRIPTION =
"Reads and sends iMessage and SMS on the Mac signed in to Messages, straight out of chat.db with node's own SQLite: recent messages, one person's thread, a phrase search, the handles seen, and attachment paths. A send to anyone but the owner stages for his decision. Needs macOS Full Disk Access rather than a credential, unlike snappy-whatsapp and snappy-telegram. Use when asked what someone texted, to search the message archive, or to text a phone number. Triggers on: imessage, text message, sms, Messages app, chat.db, text someone, what did they text.";
/** THE TYPED CONTRACT OF THIS HAND. What each verb takes, in order, and what it
* does to the world. Snappy's daemon reads it (`api.ts contract`) to validate
* an MCP call or an OpenUI button, build the argument words, run reversible
* verbs directly and stage the rest. It is the one representation of this
* hand's grammar -- the usage lines above must agree with it. */
export const HAND_CONTRACT = {
skill: "snappy-imessage",
description: CONTRACT_DESCRIPTION,
kind: "tool",
/** ⟨ORG-R6, 2026-09-06⟩ Snappy's own credential store holds this login, so
* the account a receipt names is one this product can rotate and pin. */
managed: true,
/** THE KEYS THIS HAND ASKS FOR, BY NAME -- never their values. Measured from
* the `env(...)` reads in this file: `ownerPhone()` is the only one, and it
* is OPTIONAL by design (absent, every send stages, which is the safe
* direction). Full Disk Access is NOT here because it is not a key; see
* `capabilities` below. */
requires: [] as string[],
/** WHAT THIS HAND NEEDS THAT IS NEITHER A CREDENTIAL NOR A GRANT ⟨lane
* mini-reads, 2026-09-09⟩. `fullDiskAccessRefusal()` refuses
* `service_unavailable` when chat.db is simply not on this Mac — a
* condition no key and no TCC pane fixes — and nothing in the contract said
* so, so a picker could pick a read that cannot answer here. Declared, a
* census can check the file exists before choosing this hand. */
resources: {
"messages-database": pinnedFile({
id: "messages-database",
name: "the Messages database (chat.db)",
path: "~/Library/Messages/chat.db",
why: "every read verb walks it; a Mac not signed in to Messages has none",
}),
},
/** WHAT THIS MAC MUST GRANT, which is a different thing from a credential.
* Neither is fixable by editing `.env.cache`, and both refuse by name. */
capabilities: {
"macos-full-disk-access": "Reads ~/Library/Messages/chat.db. Every read verb refuses without it, naming the System Settings pane.",
"macos-automation-messages": "Sends through Messages.app by AppleEvent. Only `send` needs it.",
},
/** THE CLOSED TABLE OF WAYS THIS HAND SAYS NO. Each names the slice of this
* contract that was violated and the move that fixes it, so a refusal is
* actionable rather than a stack trace. */
/** A PROJECTION OF THE ONE TABLE, never a second one ⟨lane refusals-2,
* 2026-09-09⟩. These eight rows used to be written out here in this hand's
* own words — `no_full_disk_access`, `chat_db_locked`, `bad_phone_number` —
* which is the drift CLAUDE.md §4 bans: a caller who had enumerated the
* collection's refusal words could not branch on any of them. The
* conditions are unchanged; each is now said with the collection's word.
* A macOS grant is `capability_not_granted` (a TCC pane, not a key and not
* a provider scope); a Mac with no Messages database and a runtime with no
* node:sqlite are both `service_unavailable`; a locked chat.db is
* `precondition_failed`; a phone number that is not E.164 is
* `invalid_argument`. */
refusals: refusalTable(
"capability_not_granted",
"service_unavailable",
"precondition_failed",
"invalid_argument",
"missing_argument",
"unknown_verb",
),
verbs: {
// THE COUNT IS A FLAG, NOT THE SECOND WORD (R59, measured 2026-09-08).
// `limit` used to be the second positional, so the next thing an AI wrote
// after the first call -- `recent 5` -- filed the 5 as a CONTACT and
// answered the last 10 messages of a person who does not exist.
recent: {
args: ["contact?"], effect: "read", class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
flags: { limit: "--limit", json: "--json" },
inputSchema: { properties: {
contact: { type: "string", description: "Optional phone in E.164, Apple ID email, or part of a conversation's name; omit for the newest messages across every conversation" },
limit: { type: "integer", description: "How many messages to answer, newest first", default: LIMIT.default, maximum: LIMIT.max },
} },
},
/** `thread` IS `recent <contact>`, SPELLED AS THE SHAPE. The face join
* derives a face from the verb's own word, and "recent" folds onto `list`
* -- a shape the imessage family declares NO face for, and the wrong one
* anyway: the messages with one person are a conversation, not a page of
* things. It takes the contact as a REQUIRED word, because a thread with
* nobody in it is several people's messages in one conversation, which is
* a lie the reader cannot see through. */
thread: {
args: ["contact"], effect: "read", class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
flags: { limit: "--limit", json: "--json" },
inputSchema: { properties: {
contact: { type: "string", description: "The person: a phone in E.164, an Apple ID email, or part of the conversation's name" },
limit: { type: "integer", description: "How many of that conversation's messages to answer, newest first", default: LIMIT.default, maximum: LIMIT.max },
} },
},
search: {
args: ["text"], effect: "read", class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
flags: { limit: "--limit", json: "--json" },
inputSchema: { properties: {
text: { type: "string", description: "The phrase to find in message bodies, matched case-insensitively" },
limit: { type: "integer", description: "How many matches to answer, newest first", default: LIMIT.default, maximum: LIMIT.max },
} },
},
contacts: {
args: [] as string[], effect: "read", class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
flags: { limit: "--limit", json: "--json" },
inputSchema: { properties: {
limit: { type: "integer", description: "How many correspondents to answer, most recently active first", default: LIMIT.default, maximum: LIMIT.max },
} },
},
attachments: {
args: ["contact"], effect: "read", class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
flags: { limit: "--limit", json: "--json" },
inputSchema: { properties: {
contact: { type: "string", description: "Whose conversation's files to list: a phone in E.164, an Apple ID email, or part of the conversation's name" },
limit: { type: "integer", description: "How many files to answer, newest first", default: LIMIT.default, maximum: LIMIT.max },
} },
},
// A send names its TARGET so the staged row can say who it reaches. There
// is deliberately no separate `stage` verb: `send` without `--now` IS the
// stage, so a caller cannot pick the arm that skips the decision.
send: {
args: ["phone", "text"], effect: "send", class: "send-to-a-person", execution: "call",
openWorld: true, target: "phone",
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
// `--now` IS NOT DECLARED HERE, and that is the collection's convention
// (snappy-telegram's `send` declares none either). It is the STAGE DOOR's
// execution word -- what the owner's decision appends when it runs this
// same verb -- not part of this hand's grammar. Declaring it would put a
// mode switch on a write verb and invite a caller to reach for the arm
// that skips the decision.
/** `--json` ON A WRITE VERB IS A PREVIEW ⟨the owner's shape law, 2026-09-09
* 01:5x⟩, never a second output format: it prints the message inside the
* conversation it joins and touches nothing. Undeclared, the daemon
* refuses the flag at the door and the preview is unreachable. */
flags: { json: "--json" },
inputSchema: { properties: {
phone: { type: "string", description: "Who receives it, in E.164, for example +14155551212" },
text: { type: "string", description: "The message body, sent exactly as written" },
} },
},
},
} as const;
/** IS THIS FILE THE COMMAND, or is something importing it? `realpathSync`
* because skills are symlinked from `~/.claude/skills` into the kernel and
* `process.argv[1]` is the symlink path (the non-realpath form silently never
* fires; 2026-09-02). The argv[1] guard is 2026-09-09: under `node -e` and
* under some runners argv[1] is UNDEFINED, and `realpathSync(undefined)` threw
* ENOENT at import time — so merely importing this hand from another module
* crashed the caller before a single verb ran. */
function isDirectRun(): boolean {
const entry = process.argv[1];
if (entry === undefined || entry === "") return false;
try { return import.meta.url === `file://${realpathSync(entry)}`; } catch { return false; }
}
if (isDirectRun() && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
/** THE OWNER'S OWN NUMBER -- his phone, in E.164, from the machine's own env.
* `SNAPPY_OWNER_PHONE` is the agnostic spelling; `ROBERT_PHONE` is the one
* this cache already holds. Absent means the rule cannot fire and EVERY send
* stages, which is the safe direction: an unset key must never widen what
* sends without a decision. That is also why it is not in `requires` -- a
* requirement would refuse the read verbs over a key only `send` consults. */
function ownerPhone(): string | null {
for (const key of ["SNAPPY_OWNER_PHONE", "ROBERT_PHONE"]) {
try { const value = env(key, false)?.trim(); if (value) return value; } catch { /* unset */ }
}
return null;
}
/** THE ONE SENTENCE THE OWNER CAN ACT ON for a send. Messages automation is a
* macOS Automation grant. Denied, the AppleEvent hangs (the prompt nobody
* sees) or answers -1743 / -1712; a raw ETIMEDOUT told the agent nothing. */
const AUTOMATION_SENTENCE = "Messages automation is not allowed for this app on this Mac: System Settings → Privacy & Security → Automation → allow Messages for the app that runs this, then run this again.";
function local(command: string): string {
try {
return execSync(command, { encoding: "utf-8", timeout: 20_000, stdio: ["ignore", "pipe", "pipe"] }).trim();
} catch (error) {
const text = error instanceof Error ? `${error.message} ${(error as { stderr?: string }).stderr ?? ""}` : String(error);
if (/ETIMEDOUT|-1743|-1712|-600|not allowed|Not authorized/u.test(text)) throw new Error(AUTOMATION_SENTENCE);
throw error;
}
}
/** Send an iMessage to a phone number (E.164 format). */
export function sendIMessage(to: string, text: string): string {
if (!/^\+\d{10,15}$/.test(to)) {
throw new Error(`Invalid E.164 phone number: ${to}. Give it as +14155551212.`);
}
const escapedText = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return local(
`osascript -e 'tell application "Messages" to send "${escapedText}" to buddy "${to}" of (service 1 whose service type is iMessage)'`
);
}
/** THE GRAMMAR OF A READ, owned in ONE place so the CLI and its test read the
* same words. A leftover positional count REFUSES by name rather than being
* silently dropped: a dropped count answers the default and looks like a real
* answer, which is the failure this rule exists to end. */
export function parseRecentMessagesArgs(args: string[]): { contact?: string; limit: number; refusal?: string } {
const positional = args.filter((arg, index) => !arg.startsWith("--") && args[index - 1] !== "--limit");
const limitAt = args.indexOf("--limit");
const limit = limitAt >= 0 ? Number(args[limitAt + 1]) || LIMIT.default : LIMIT.default;
if (positional.length > 1) {
return { limit, refusal: `recent takes one contact; the count is a flag: recent ${positional[0]} --limit ${positional[1]}` };
}
return { contact: positional[0] || undefined, limit };
}
/* ── THE FACE THIS READ TAKES ────────────────────────────────────────────────
*
* `--json` PRINTS THE FACE'S OBJECT where an iMessage face draws the answer.
* Without `--json` the rows print as `date<TAB>sender<TAB>text`, which is what
* a shell pipeline and the channel adapter read.
*
* A CONTACTLESS `recent` IS NOT A THREAD, AND SAYS SO. With no contact the
* reader answers the last N messages ACROSS EVERY CHAT -- several people at
* once. Drawing that as one thread would put three people's words in one
* conversation with left/right bubbles implying a single correspondent, which
* is a lie the reader cannot see through. snappy-faces wires FOUR imessage
* kinds -- one, thread, compose, decision (faces-wired.ts, re-measured
* 2026-09-09) -- and NO list, so a contactless `recent --json` prints its own
* {count, messages} answer and NO kind: a face nobody built is not a face to
* fake, and `contacts` is the verb that answers "who has been texting".
*/
/** `thread <contact>` (and `recent <contact>`) → the `imessage-thread` face.
*
* EVERY FIELD THE FACE DECLARES IS NOW FILLED, which the retired python
* reader could not do: it printed three columns, so `service` (the blue/green
* bubble), `receipt` (Delivered / Read) and `reaction` (a tapback) were always
* null and the conversation drew flat. Reading chat.db directly, they come off
* the columns that hold them. */
export function imessageThreadFace(rows: IMessageRow[], chat: string | null = null): Record<string, unknown> {
return {
kind: "imessage-thread",
messages: rows.map((row) => ({
id: row.id,
// The face declares `text` as a REQUIRED string. A message whose whole
// content is an attachment has no words at all, and the honest stand-in
// is what Messages itself shows -- the file's name -- never "[no text]".
text: row.text ?? (row.attachments.length > 0 ? row.attachments.join(", ") : ""),
date: row.date ?? "",
outgoing: row.outgoing,
from: row.from,
service: row.service,
receipt: row.receipt,
reaction: row.reaction,
})),
chat: chat ?? rows.find((row) => row.chat !== null)?.chat ?? null,
total: rows.length,
};
}
/** THE ANSWER IN THE CONVERSATION IT ANSWERS ⟨the owner's shape law, 2026-09-09
* 01:5x: "for ANY message it should show the THREAD — WhatsApp, iMessage,
* Statechange, Gmail, comments, everything; you don't just show me the email
* you're going to send, you show it in the context"⟩.
*
* `thread` is the SAME rows `imessageThreadFace` prints — one representation
* ⟨CLAUDE.md §4⟩ — so the conversation a person reads before approving a text
* is the conversation `thread --json` would have drawn, service colours,
* receipts and tapbacks and all.
*
* THE BUBBLE'S COLOUR IS A CLAIM ABOUT WHICH WIRE IT GOES OUT ON, so `asSms`
* is carried only when the conversation's OWN last message says SMS. Guessing
* green would tell a person their words are leaving as a text message when
* Messages will send them as an iMessage, or the reverse — a field that looks
* filled and is wrong ⟨CLAUDE.md §10⟩.
*
* THE FDA REFUSAL IS NOT THIS FUNCTION'S PROBLEM AND MUST NOT BECOME ONE. A
* Mac that will not open chat.db has no conversation to show, and the honest
* answer there is the refusal the read already gives — never an empty thread,
* which would draw as "you have never spoken to this person". The CLI refuses
* before it reaches here. */
export function imessageDecisionFace(input: {
rows: IMessageRow[]; to: string; body: string; chat?: string | null; waitingWords?: string | null;
/** THE ACT A PRESS RUNS ⟨lane doors-everywhere, 2026-09-09⟩: this hand's own
* contract verb and `HAND_CONTRACT.verbs[verb].args` verbatim. Without it the
* door was a button nothing could build a press for. */
act: { verb: string; args: readonly string[]; values?: Record<string, unknown> };
}): DecisionInContext {
const drawn = imessageThreadFace(input.rows, input.chat ?? null);
const rows = drawn.messages as Record<string, unknown>[];
const who = (drawn.chat as string | null) ?? input.to;
// The conversation's own service, off its most recent message. `null` where
// the read carried none, and then nothing is claimed either way.
const service = [...input.rows].reverse().find((row) => row.service !== null)?.service ?? null;
return decisionInContext({
decisionKind: "imessage-decision",
composeKind: "imessage-compose",
threadKind: "imessage-thread",
thread: rows,
threadTotal: rows.length,
draft: {
to: who,
body: input.body,
...(service === "sms" ? { asSms: true } : {}),
...(rows.length > 0 ? { waitingWords: input.waitingWords ?? null } : {}),
// THE ACT'S OWN WORDS ⟨doors-everywhere⟩. `to` is the person's NAME when
// the conversation knows one, which is what Messages draws over a chat
// and is not something you can send to; `phone` is the contract's word
// and the number the act really takes.
phone: input.to,
text: input.body,
},
act: input.act,
doors: standingDoors(`sends the message to ${who} from this Mac now`),
});
}
/** THE ONE PLACE a verb's answer becomes its face. Null for a read no iMessage
* face draws -- see the contactless `recent` note above.
*
* NAMED FOR ITS HAND ⟨X1, 2026-09-09⟩: ten hands exported `faceForVerb` and
* the collection lint's one-exported-name rule failed all of them. The other
* eight stopped exporting theirs; this one is exported because `face.test.ts`
* asserts on it directly, so it carries its hand's name instead. */
export function imessageFaceForVerb(command: string, contact: string | undefined, rows: IMessageRow[]): Record<string, unknown> | null {
if (command !== "recent" && command !== "thread" && command !== "search") return null;
if (command !== "search" && contact === undefined) return null;
return imessageThreadFace(rows, command === "search" ? null : contact ?? null);
}
/** A refusal printed the one way — ON STDOUT, as one JSON object, exit 1. A
* read that cannot run must never look like a read that found nothing: an
* empty list is an answer, and this is not one.
*
* IT WAS ON STDERR ⟨measured 2026-09-09: `recent` printed the envelope to
* stderr and NOTHING to stdout⟩. stderr is where a runner puts noise it does
* not read; the refusal is the ANSWER to the call, so it goes where the
* answer goes. `printRefusal` from the one table owns that choice now, so it
* cannot be made differently in the next hand. */
function refuse(refusal: ChatDbRefusal): never {
printRefusal(refusal);
process.exit(1);
}
/** The rows, or the refusal, printed and exited. One place, so every read verb
* refuses identically. */
function rowsOrRefuse<T>(answer: { rows: T } | { refusal: ChatDbRefusal }): T {
if ("refusal" in answer) refuse(answer.refusal);
return answer.rows;
}
// --- CLI ---
if (isDirectRun()) {
(async () => {
const [, , cmd, ...args] = process.argv;
const json = args.includes("--json");
switch (cmd) {
case "send": {
// `--now` is the hand door's execution word, never part of the text;
// `--json` is the preview, and neither is ever a word of the message.
const [to, ...textParts] = args.filter((a) => a !== "--now" && a !== "--json");
if (!to || !textParts.length) { refuseCli("missing_argument", "send needs the phone number and the text; contract slice verbs.send.args = [to, text]. Usage: api.ts send <phone> <text> [--now] [--json]"); process.exit(1); }
const text = textParts.join(" ");
// A PREVIEW TOUCHES NOTHING ⟨the owner's shape law, 2026-09-09 01:5x⟩:
// nothing sent, nothing staged. The conversation is READ FIRST, because
// it is what the person approving reads — and a Mac that will not open
// chat.db REFUSES here exactly as every read verb does, naming the
// System Settings pane. An empty thread would draw as "you have never
// spoken to this person", which is a lie about the machine's
// permissions dressed as a fact about the conversation.
if (args.includes("--json")) {
const rows = rowsOrRefuse(readMessages(to, LIMIT.default));
console.log(JSON.stringify(imessageDecisionFace({ rows, to, body: text,
act: { verb: "send", args: HAND_CONTRACT.verbs.send.args } }), null, 2));
break;
}
// THE OWNER HIMSELF IS NOT "anyone" (employee model, 2026-09-06). A
// text to the owner's own phone is the owner being told what he asked
// to be told -- it sends, and the receipt is the proof. A text to ANY
// OTHER number is a send to a person: it goes through the stage door
// and the decision runs this exact verb with `--now`. An unset owner
// number stages everything, because a missing key must never widen
// what may send.
const owner = ownerPhone();
if (to !== owner && !args.includes("--now")) {
const staged = await stageHandOperation({
skill: "snappy-imessage", verb: "send", argv: ["{{phone}}", "{{text}}"],
fields: { phone: to, text, title: `iMessage to ${to}`, body: text },
target: "imessage", facet: "chat-message",
action_label: `Send iMessage to ${to}`, reversible: false, risk: "medium",
});
if (staged.staged) { console.log(`staged for approval: control ${staged.control_id} (the decision sends it)`); break; }
console.error(`not staged: ${JSON.stringify(staged.answer).slice(0, 300)}`); process.exit(1);
}
console.log(sendIMessage(to, text));
console.log(`sent to ${to === owner ? "the owner's own phone" : to}`);
break;
}
case "recent": case "thread": {
const { contact, limit, refusal } = parseRecentMessagesArgs(args);
if (refusal) { refuseCli("invalid_argument", refusal); process.exit(1); }
if (cmd === "thread" && !contact) { refuseCli("missing_argument", "thread needs the contact whose messages to read; contract slice verbs.thread.args = [contact]. Usage: api.ts thread <contact> [--limit N] [--json]"); process.exit(1); }
const rows = rowsOrRefuse(readMessages(contact, limit));
if (!json) { printRows(rows); break; }
const face = imessageFaceForVerb(cmd, contact, rows);
console.log(JSON.stringify(face ?? { count: rows.length, messages: rows }, null, 2));
break;
}
case "search": {
const { contact: phrase, limit, refusal } = parseRecentMessagesArgs(args);
if (refusal) { refuseCli("invalid_argument", refusal); process.exit(1); }
if (!phrase) { refuseCli("missing_argument", "search needs the text to look for; contract slice verbs.search.args = [text]. Usage: api.ts search <text> [--limit N] [--json]"); process.exit(1); }
const found = rowsOrRefuse(searchMessages(phrase, limit));
if (!json) {
printRows(found.rows);
if (found.truncated) console.error(`(searched the newest ${found.scanned} messages whose text is stored as an archive; older ones were not decoded)`);
break;
}
console.log(JSON.stringify({ ...imessageThreadFace(found.rows), scanned: found.scanned, truncated: found.truncated }, null, 2));
break;
}
case "contacts": {
const { limit } = parseRecentMessagesArgs(args);
const rows = rowsOrRefuse(readContacts(limit));
if (json) { console.log(JSON.stringify({ count: rows.length, contacts: rows }, null, 2)); break; }
for (const row of rows) console.log(`${row.last ?? "?"}\t${row.handle}\t${row.chat ?? ""}\t${row.messages}`);
break;
}
case "attachments": {
const { contact, limit } = parseRecentMessagesArgs(args);
if (!contact) { refuseCli("missing_argument", "attachments needs the contact whose files to list; contract slice verbs.attachments.args = [contact]. Usage: api.ts attachments <contact> [--limit N] [--json]"); process.exit(1); }
const rows = rowsOrRefuse(attachmentsFor(contact, limit));
if (json) { console.log(JSON.stringify({ count: rows.length, attachments: rows }, null, 2)); break; }
for (const row of rows) console.log(`${row.date ?? "?"}\t${row.name}\t${row.path ?? ""}`);
break;
}
case "contract": { console.log(JSON.stringify(HAND_CONTRACT, null, 2)); break; }
// A WORD OUTSIDE THE CONTRACT IS A REFUSAL, NOT A USAGE LINE ⟨lane
// refusals-2, 2026-09-09⟩. This arm printed the usage to stdout and
// exited 0, so a caller that asked for a verb this hand does not have
// was told the call SUCCEEDED. Asking for help still gets help.
default: {
const usage = "Usage: npx tsx api.ts [recent|thread|search|contacts|attachments|send] ... [--limit N] [--json]\n--json on thread/search prints the iMessage face's own object (imessage-thread).\n--json on send PREVIEWS the decision in its context ({kind, thread, draft, doors}) and touches nothing.";
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") { console.log(usage); break; }
refuseCli("unknown_verb", `unknown verb "${cmd}"; contract slice verbs = [${Object.keys(HAND_CONTRACT.verbs).join(", ")}]`);
break;
}
}
})();
}
/** The plain-text rendition, which is what a shell pipeline and `adapter.ts`
* read. One tab-separated line per message, the reader's own words. */
function printRows(rows: IMessageRow[]): void {
for (const row of rows) {
console.log(`${row.date ?? "?"}\t${row.outgoing ? "me" : row.from ?? "unknown"}\t${(row.text ?? "").slice(0, 500)}`);
}
}
/** Read the newest messages, as a module. Kept as the exported name callers
* already spell (`adapter.ts` and the channel road). */
export function readRecent(contact?: string, limit = LIMIT.default): IMessageRow[] {
const answer = readMessages(contact, limit);
if ("refusal" in answer) throw new Error(`${answer.refusal.message} ${answer.refusal.fix}`);
return answer.rows;
}
/**
* snappy-imessage/chat-db.ts — THE READER, INSIDE THE SKILL.
*
* WHY THIS FILE EXISTS ⟨the owner, 2026-09-08 23:5x; measured 2026-09-09⟩.
* `readRecent` used to shell out to `/usr/bin/python3 ~/.snappy/imessage-read.py`
* — a file this skill does not ship, does not own, and cannot install. Measured
* on this Mac the same night: `~/.snappy/imessage-read.py` DOES NOT EXIST, so
* every read verb refused with "the reader is not installed" on the one machine
* that holds chat.db. A road that needs a stray file in someone's home
* directory is not three files, and the owner's rule is that anything can be.
*
* SO THE READER IS `node:sqlite`, WHICH IS IN NODE ITSELF (measured: node
* v25.8.1 here answers `require("node:sqlite")` with DatabaseSync, StatementSync,
* Session, constants, backup — no flag, no package, no build step). That is
* strictly better than the two roads it replaces: no python, no `better-sqlite3`
* native build (which CLAUDE.md records breaking on Homebrew node 26), and no
* second file. The `sqlite3(1)` CLI on this Mac is 3.43.2 and would also work,
* but a shell-out is a second road to the same data — banned by CLAUDE.md §4 —
* so it is not a fallback here. A runtime with no `node:sqlite` REFUSES BY NAME.
*
* ONE COMPUTER. This reads the chat.db of the Mac it runs on. See SKILL.md
* "Which Mac" — Messages is signed in on the Mini, and a Mac with no Messages
* account says so instead of answering an empty thread.
*/
import { accessSync, constants, existsSync } from "node:fs";
import { createRequire } from "node:module";
import { homedir } from "node:os";
import { join } from "node:path";
import { refuse, type Refusal } from "../snappy-settings/refusal-codes.ts";
/** This file is ESM, and `node:sqlite` must be reached in a way that CANNOT
* throw at module load: a runtime without it has to be able to LOAD this file
* in order to print the refusal that says so. A static `import` would kill the
* process before the refusal existed; a top-level `await import` would make
* every consumer async. `createRequire` is the one form that is lazy AND
* synchronous. */
const requireBuiltin = createRequire(import.meta.url);
/* ── WHERE THE DATABASE IS ─────────────────────────────────────────────── */
/** Messages' own store. `SNAPPY_IMESSAGE_DB` points the reader at a copy —
* that is how the tests run against a fixture database without Full Disk
* Access and without a single real message ever reaching this repo. */
export function chatDbPath(): string {
const override = process.env.SNAPPY_IMESSAGE_DB;
if (override !== undefined && override !== "") return override;
return join(homedir(), "Library", "Messages", "chat.db");
}
/* ── THE TWO REFUSALS A READ CAN OWE, EACH NAMING ITS OWN FIX ──────────── */
/** A refusal is a typed envelope, never a thrown stack. `outcome` is the
* discriminant — CLAUDE.md's rule that a falsy check over a typed failure is
* how a refusal gets read as an acceptance.
*
* IT IS THE COLLECTION'S ENVELOPE, NOT A SECOND ONE ⟨lane refusals-2,
* 2026-09-09⟩. This file used to spell its own four-code union — a second
* refusal table, which CLAUDE.md §4 bans outright: `no_full_disk_access`
* meant nothing to a caller who had enumerated the collection's words, and
* the envelope carried no `contract_slice`, so a reader could not find the
* slice of the contract that was violated. The CONDITIONS did not change;
* only the word each one is said with, and it now comes from the one table. */
export type ChatDbRefusal = Refusal;
/** THE ONE SENTENCE THE OWNER CAN ACT ON, written once because it is printed
* from three places and a fix that drifts between them is a fix nobody can
* follow. It names the PANE, not the concept. */
export const FDA_FIX =
"System Settings → Privacy & Security → Full Disk Access → add the app that runs this (Terminal, or SnappyOS), toggle it on, then quit and reopen that app and run this again.";
/** FULL DISK ACCESS IS THE REAL REQUIREMENT, and it is a MACHINE CAPABILITY,
* not a credential: there is no key to put in `.env.cache`, so it can never be
* a `requires` entry. macOS answers a denied read with EPERM at `access(2)` —
* before any open, in microseconds — which is what makes the sub-50 ms refusal
* possible at all. MEASURED on this Mac 2026-09-09: `accessSync(R_OK)` throws
* EPERM, `cp` says "Operation not permitted", the sqlite3 CLI says
* "authorization denied", and node:sqlite says "unable to open database file".
* Four different sentences for one cause; this function is the one that names
* the cause and the pane that fixes it. */
export function fullDiskAccessRefusal(path = chatDbPath()): ChatDbRefusal | null {
if (!existsSync(path)) {
// THE MAC ITSELF IS THE SERVICE. No credential and no grant fixes this:
// the Messages database is not on this machine, so the caller's move is to
// run the hand where it is — `service_unavailable`'s own condition.
return refuse(
"service_unavailable",
`There is no Messages database on this Mac: ${path} does not exist, so there are no iMessages here to read. Run this on the Mac that is signed in to Messages (the Mini); see snappy-imessage/SKILL.md, "Which Mac".`,
);
}
try {
accessSync(path, constants.R_OK);
return null;
} catch {
return refuse(
"capability_not_granted",
`This process cannot read ${path}. macOS protects Messages behind Full Disk Access, and this runner has not been granted it. ${FDA_FIX}`,
);
}
}
/* ── THE HANDLE ON THE DATABASE ────────────────────────────────────────── */
/** The shape of `node:sqlite`'s DatabaseSync that this file uses. Declared
* structurally so nothing here has to import the module at type level — a
* runtime without it must still be able to LOAD this file in order to print
* the refusal that says so. */
interface ReadableDb {
prepare(sql: string): { all(...params: unknown[]): unknown[] };
close(): void;
}
/** THE ONE OPEN. Read-only, and every failure it can have is a named refusal
* rather than a sqlite error string the reader has to guess at. */
export function openChatDb(path = chatDbPath()): { db: ReadableDb } | { refusal: ChatDbRefusal } {
const denied = fullDiskAccessRefusal(path);
if (denied !== null) return { refusal: denied };
let DatabaseSync: new (p: string, o?: Record<string, unknown>) => ReadableDb;
try {
({ DatabaseSync } = requireBuiltin("node:sqlite") as {
DatabaseSync: new (p: string, o?: Record<string, unknown>) => ReadableDb;
});
} catch {
return {
refusal: refuse(
"service_unavailable",
`This runtime has no node:sqlite, so nothing here can open a SQLite file. It is running ${process.version}. Run this hand on node 22.5 or newer (node:sqlite is built in; this Mac's node 25 has it) — there is no package to install.`,
),
};
}
try {
return { db: new DatabaseSync(path, { readOnly: true }) };
} catch (error) {
const text = error instanceof Error ? error.message : String(error);
if (/locked|busy/iu.test(text)) {
return {
refusal: refuse(
"precondition_failed",
`Messages is holding ${path} and would not let this read in: ${text}. Try again in a moment; if it persists, quit Messages once and run this again.`,
),
};
}
// The access(2) probe passed and the open still failed: on macOS that is
// the -wal/-shm sidecars being withheld, which is the SAME grant. Named as
// the same cause, so the fix printed is the one that actually works.
return {
refusal: refuse(
"capability_not_granted",
`This process could stat ${path} but could not open it (${text}) — macOS is withholding Messages' write-ahead log from this runner. ${FDA_FIX}`,
),
};
}
}
/* ── APPLE'S CLOCK ─────────────────────────────────────────────────────── */
/** Seconds between the Unix epoch and Apple's (2001-01-01T00:00:00Z). */
const APPLE_EPOCH_OFFSET_S = 978_307_200;
/** chat.db's `date`, as an ISO string. Two encodings live in one column: since
* macOS 10.13 it is NANOSECONDS since 2001-01-01, and before that it was
* SECONDS — a database that has been carried across upgrades holds both, so a
* reader that assumes one silently dates half the archive to 2001. The
* threshold is the only honest discriminant: a seconds value large enough to
* be a nanosecond count would be the year 33,000. Zero means "never" (an
* unset date_read), and answers null rather than 2001-01-01. */
export function appleDateToIso(raw: number | bigint | string | null | undefined): string | null {
if (raw === null || raw === undefined || raw === "") return null;
const value = typeof raw === "string" ? Number(raw) : typeof raw === "bigint" ? Number(raw) : raw;
if (!Number.isFinite(value) || value === 0) return null;
const seconds = Math.abs(value) > 1e11 ? value / 1e9 : value;
const ms = (seconds + APPLE_EPOCH_OFFSET_S) * 1000;
const date = new Date(ms);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
/* ── WHERE MODERN iMESSAGE KEEPS THE WORDS ─────────────────────────────── */
/** THE `text` COLUMN IS OFTEN NULL, and the message is in `attributedBody` —
* an Apple `typedstream` (NSArchiver) blob holding an NSAttributedString.
* This is why the retired python reader existed at all.
*
* WHAT THIS DECODES, EXACTLY: the single-NSString case, which is every plain
* message. After the archived class name `NSString` the stream carries a
* short header and then `+` (0x2B), which introduces the bytes: a length,
* then UTF-8. Lengths under 0x81 are the byte itself; 0x81 marks a 2-byte
* little-endian length, 0x82 a 4-byte one.
*
* WHAT IT DOES NOT DO, and answers null for rather than guessing: a blob with
* no NSString (an attachment-only message), and anything whose declared length
* runs off the end of the buffer. Null means "this hand could not read it",
* and the row still draws with its date, its sender and its attachments —
* a message with an honest empty body beats an invented one. The plain `text`
* column WINS whenever it is non-null; this runs only when it is not. */
export function decodeAttributedBody(blob: Uint8Array | Buffer | null | undefined): string | null {
if (blob === null || blob === undefined) return null;
const buf = Buffer.isBuffer(blob) ? blob : Buffer.from(blob);
if (buf.length === 0) return null;
const marker = buf.indexOf("NSString", 0, "latin1");
if (marker < 0) return null;
// The `+` that introduces the payload sits within a few bytes of the class
// name (Apple writes `\x01\x94\x84\x01+`). A bounded search, so a blob that
// merely mentions NSString elsewhere cannot drag the cursor across the file.
const windowEnd = Math.min(buf.length, marker + 8 + 16);
const plus = buf.indexOf(0x2b, marker + 8);
if (plus < 0 || plus >= windowEnd) return null;
let p = plus + 1;
if (p >= buf.length) return null;
let length: number;
if (buf[p] === 0x81) {
if (p + 3 > buf.length) return null;
length = buf.readUInt16LE(p + 1);
p += 3;
} else if (buf[p] === 0x82) {
if (p + 5 > buf.length) return null;
length = buf.readUInt32LE(p + 1);
p += 5;
} else {
length = buf[p];
p += 1;
}
if (length <= 0 || p + length > buf.length) return null;
const text = buf.subarray(p, p + length).toString("utf8");
return text === "" ? null : text;
}
/* ── TAPBACKS ──────────────────────────────────────────────────────────── */
/** A tapback is a MESSAGE ROW, not a property of the message it lands on —
* `associated_message_type` says which one and `associated_message_guid`
* points at the target. Left unmapped they draw as separate empty bubbles in
* the middle of a conversation, which is what Messages never shows. */
const TAPBACK: Record<number, string> = {
2000: "❤️", 2001: "👍", 2002: "👎", 2003: "😂", 2004: "‼️", 2005: "❓",
};
/** The emoji for an `associated_message_type`, or null when the row is an
* ordinary message (0) or a REMOVED tapback (3000-3005), which leaves nothing
* on the target and so is dropped rather than drawn. */
export function tapbackEmoji(associatedMessageType: number | null | undefined): string | null {
if (associatedMessageType === null || associatedMessageType === undefined) return null;
return TAPBACK[associatedMessageType] ?? null;
}
/** chat.db writes the target as `p:0/<guid>` or `bp:<guid>`; the guid is the
* part after the last `/` or `:`. */
export function tapbackTargetGuid(associatedMessageGuid: string | null | undefined): string | null {
if (typeof associatedMessageGuid !== "string" || associatedMessageGuid === "") return null;
const slash = associatedMessageGuid.lastIndexOf("/");
if (slash >= 0) return associatedMessageGuid.slice(slash + 1);
const colon = associatedMessageGuid.lastIndexOf(":");
return colon >= 0 ? associatedMessageGuid.slice(colon + 1) : associatedMessageGuid;
}
/* ── ONE MESSAGE, AS THIS SKILL READS IT ───────────────────────────────── */
export interface IMessageRow {
/** chat.db's own guid — one message, one id, whichever road read it. */
id: string;
/** The words. Null only when neither `text` nor `attributedBody` carried any
* (an attachment-only message), never an invented placeholder. */
text: string | null;
/** ISO 8601, from Apple's clock. */
date: string | null;
/** True when we sent it. */
outgoing: boolean;
/** The correspondent's handle (phone in E.164, or an Apple ID email). Null on
* our own messages, which is what the face wants. */
from: string | null;
/** Blue or green. chat.db spells it "iMessage" / "SMS". */
service: "imessage" | "sms" | null;
/** Only ever set on an outgoing message: nothing tells us whether THEY read
* ours except our own copy's date_read. */
receipt: "sent" | "delivered" | "read" | null;
/** A tapback someone left on this message. */
reaction: string | null;
/** How Messages titles the conversation this message sits in. */
chat: string | null;
/** File names only — see `attachments`. */
attachments: string[];
}
const MESSAGE_SELECT = `
SELECT m.ROWID AS rowid,
m.guid AS guid,
m.text AS text,
m.attributedBody AS attributed_body,
CAST(m.date AS TEXT) AS date,
CAST(m.date_read AS TEXT) AS date_read,
CAST(m.date_delivered AS TEXT) AS date_delivered,
m.is_from_me AS is_from_me,
m.is_sent AS is_sent,
m.is_delivered AS is_delivered,
m.service AS service,
m.associated_message_type AS associated_type,
m.associated_message_guid AS associated_guid,
h.id AS handle,
COALESCE(NULLIF(c.display_name, ''), c.chat_identifier) AS chat_name
FROM message m
LEFT JOIN handle h ON h.ROWID = m.handle_id
LEFT JOIN chat_message_join j ON j.message_id = m.ROWID
LEFT JOIN chat c ON c.ROWID = j.chat_id
WHERE COALESCE(m.associated_message_type, 0) = 0
`;
interface RawRow {
rowid: number; guid: string | null; text: string | null;
attributed_body: Uint8Array | null; date: string | null;
date_read: string | null; date_delivered: string | null;
is_from_me: number | null; is_sent: number | null; is_delivered: number | null;
service: string | null; associated_type: number | null; associated_guid: string | null;
handle: string | null; chat_name: string | null;
}
/** THE ONE ROW MAPPER. Every read below produces `IMessageRow` through this,
* so a column that is read one way in `recent` cannot be read another way in
* `search` (CLAUDE.md §4). */
function toRow(raw: RawRow, attachments: string[] = []): IMessageRow {
const outgoing = raw.is_from_me === 1;
const service = raw.service === null ? null
: raw.service.toLowerCase() === "sms" ? "sms" as const : "imessage" as const;
let receipt: IMessageRow["receipt"] = null;
if (outgoing) {
if (appleDateToIso(raw.date_read) !== null) receipt = "read";
else if (raw.is_delivered === 1 || appleDateToIso(raw.date_delivered) !== null) receipt = "delivered";
else if (raw.is_sent === 1) receipt = "sent";
}
return {
id: raw.guid ?? `rowid:${raw.rowid}`,
text: raw.text !== null && raw.text !== "" ? raw.text : decodeAttributedBody(raw.attributed_body),
date: appleDateToIso(raw.date),
outgoing,
from: outgoing ? null : raw.handle,
service,
receipt,
reaction: null,
chat: raw.chat_name,
attachments,
};
}
/** THE TAPBACKS ON A RUN OF MESSAGES, fetched by their targets rather than
* found among them. chat.db stores a tapback as its own message row, so when
* they shared the run's LIMIT the ask was spent on rows that never draw --
* MEASURED 2026-09-09: `thread --limit 3` answered ONE message, because two of
* the three newest rows were a tapback and its removal. The main SELECT now
* excludes them (`associated_message_type = 0`) and this asks for them by the
* guid each one points at.
*
* A REMOVAL CANCELS. chat.db keeps both the 2000-2005 row that added a tapback
* and the 3000-3005 row that took it away, so the LATEST row per target wins;
* a tapback someone removed must not still be on the message. */
function reactionsFor(db: ReadableDb, guids: string[]): Map<string, string> {
const out = new Map<string, string>();
if (guids.length === 0) return out;
// The three spellings chat.db uses for a target: `p:0/<guid>`, `bp:<guid>`,
// and the bare guid. Named explicitly so the join is an indexed equality
// rather than a LIKE scan over every message on the Mac.
const candidates = guids.flatMap((guid) => [`p:0/${guid}`, `bp:${guid}`, guid]);
const rows = db.prepare(`
SELECT associated_message_guid AS target, associated_message_type AS type
FROM message
WHERE associated_message_type BETWEEN 2000 AND 3005
AND associated_message_guid IN (${candidates.map(() => "?").join(",")})
ORDER BY date ASC
`).all(...candidates) as { target: string | null; type: number | null }[];
for (const row of rows) {
const guid = tapbackTargetGuid(row.target);
if (guid === null) continue;
const emoji = tapbackEmoji(row.type);
// Ascending date means a later row overwrites an earlier one; a removal
// (no emoji) deletes rather than writing null, so the map holds only what
// is still on the message.
if (emoji === null) out.delete(guid);
else out.set(guid, emoji);
}
return out;
}
/** Attachment file names for a run of messages, in one query rather than one
* per message. Names only — see `attachmentsFor` for the paths verb. */
function attachmentNames(db: ReadableDb, rowids: number[]): Map<number, string[]> {
const out = new Map<number, string[]>();
if (rowids.length === 0) return out;
const rows = db.prepare(`
SELECT j.message_id AS message_id,
COALESCE(a.transfer_name, a.filename) AS name
FROM message_attachment_join j
JOIN attachment a ON a.ROWID = j.attachment_id
WHERE j.message_id IN (${rowids.map(() => "?").join(",")})
`).all(...rowids) as { message_id: number; name: string | null }[];
for (const row of rows) {
if (row.name === null) continue;
const held = out.get(row.message_id) ?? [];
held.push(row.name.split("/").pop() ?? row.name);
out.set(row.message_id, held);
}
return out;
}
/** A run of raw rows finished into `IMessageRow`s, oldest first (how a
* conversation reads) with tapbacks folded in. */
function finish(db: ReadableDb, raws: RawRow[]): IMessageRow[] {
const names = attachmentNames(db, raws.map((raw) => raw.rowid));
const rows = raws.map((raw) => toRow(raw, names.get(raw.rowid) ?? []));
const reactions = reactionsFor(db, rows.map((row) => row.id));
for (const row of rows) row.reaction = reactions.get(row.id) ?? null;
return rows.reverse();
}
/** THE CEILING ON A READ. A hand that will answer 50,000 messages into a
* model's context is a hand that costs the caller their whole window; a hand
* that silently answers 10 when asked for 500 is lying. So the ask is clamped
* and the answer says what it clamped to. */
/** TWENTY, NOT TEN ⟨R17; the owner, 2026-09-09 01:5x: "20 not three"⟩. A read
* that answers ten messages of a live conversation teaches the reader the
* thread is shorter than it is, and the decision preview built on it shows a
* person a third of what they are answering. `max` is unchanged: this is a
* DEFAULT, and a caller that wants ten still asks for ten. */
export const LIMIT = { default: 20, max: 200 } as const;
export function clampLimit(asked: number | undefined): number {
if (asked === undefined || !Number.isFinite(asked) || asked <= 0) return LIMIT.default;
return Math.min(Math.floor(asked), LIMIT.max);
}
export type ReadAnswer<T> = { rows: T } | { refusal: ChatDbRefusal };
function withDb<T>(fn: (db: ReadableDb) => T, path?: string): ReadAnswer<T> {
const opened = openChatDb(path);
if ("refusal" in opened) return { refusal: opened.refusal };
try {
return { rows: fn(opened.db) };
} finally {
opened.db.close();
}
}
/** `recent [contact]` — the newest messages, across every conversation when no
* contact is named, or one person's when one is. */
export function readRecent(contact: string | undefined, limit: number, path?: string): ReadAnswer<IMessageRow[]> {
const n = clampLimit(limit);
return withDb((db) => {
const raws = contact === undefined
? db.prepare(`${MESSAGE_SELECT} ORDER BY m.date DESC LIMIT ?`).all(n) as RawRow[]
: db.prepare(`${MESSAGE_SELECT}
AND (h.id = ? OR h.id LIKE ? OR c.chat_identifier = ? OR c.display_name LIKE ?)
ORDER BY m.date DESC LIMIT ?`)
.all(contact, `%${contact}%`, contact, `%${contact}%`, n) as RawRow[];
return finish(db, raws);
}, path);
}
/** HOW FAR BACK A SEARCH DECODES. SQL can match the plain `text` column, but
* a modern message keeps its words in the `attributedBody` BLOB, which SQL
* cannot see into at all — measured against the fixture, a SQL-only search
* for a word that lives in a blob returns ZERO rows while the message is
* right there. So the second pass decodes recent blob-only rows in JS. The
* ceiling exists because the alternative is decoding a 40 MB archive on every
* search; a search that says how far it looked beats one that hangs. */
export const SEARCH_SCAN = 4000;
/** `search <text>` — messages whose words contain a phrase, case-insensitive.
*
* TWO PASSES, AND THE ANSWER SAYS SO. Pass one is SQL over `text`. Pass two
* decodes the newest `SEARCH_SCAN` messages whose `text` is NULL — the ones
* whose words are in a typedstream blob — and matches those in memory. A
* message older than that window and stored as a blob is NOT found, which is
* why `scanned` and `truncated` come back with the rows: a caller who needs
* the whole archive can see that this answer did not cover it. */
export function searchMessages(
phrase: string, limit: number, path?: string,
): ReadAnswer<{ rows: IMessageRow[]; scanned: number; truncated: boolean }> {
const n = clampLimit(limit);
const needle = phrase.toLowerCase();
return withDb((db) => {
const matched = db.prepare(
`${MESSAGE_SELECT} AND m.text LIKE ? ESCAPE '\\' ORDER BY m.date DESC LIMIT ?`,
).all(`%${phrase.replace(/[\\%_]/gu, (c) => `\\${c}`)}%`, n) as RawRow[];
const blobs = db.prepare(
`${MESSAGE_SELECT} AND m.text IS NULL AND m.attributedBody IS NOT NULL
ORDER BY m.date DESC LIMIT ?`,
).all(SEARCH_SCAN) as RawRow[];
const hits = blobs.filter((raw) => (decodeAttributedBody(raw.attributed_body) ?? "").toLowerCase().includes(needle));
// Newest first across both passes, then cut to the ask — so the two roads
// produce ONE ordered answer rather than a SQL block followed by a blob
// block, which is what a caller would have to re-sort themselves.
const both = [...matched, ...hits]
.sort((a, b) => Number(b.date ?? 0) - Number(a.date ?? 0))
.slice(0, n);
return { rows: finish(db, both), scanned: blobs.length, truncated: blobs.length >= SEARCH_SCAN };
}, path);
}
/** One correspondent as `contacts` answers them. */
export interface IMessageContact {
/** The handle Messages routes to: E.164, or an Apple ID email. */
handle: string;
/** How Messages titles the conversation, where it has a name for it. */
chat: string | null;
/** ISO of the newest message either way. */
last: string | null;
/** How many messages this Mac holds with them. */
messages: number;
}
/** `contacts` — the handles this Mac has messages with, busiest conversation
* first. NO ADDRESS BOOK IS READ: the names here are the ones Messages itself
* stored on the chat, and nothing is joined against Contacts.app, so this verb
* answers only what the person already put in this conversation. */
export function readContacts(limit: number, path?: string): ReadAnswer<IMessageContact[]> {
const n = clampLimit(limit);
return withDb((db) => db.prepare(`
SELECT h.id AS handle,
CAST(MAX(m.date) AS TEXT) AS last,
COUNT(*) AS messages,
MAX(NULLIF(c.display_name, '')) AS chat
FROM message m
JOIN handle h ON h.ROWID = m.handle_id
LEFT JOIN chat_message_join j ON j.message_id = m.ROWID
LEFT JOIN chat c ON c.ROWID = j.chat_id
WHERE COALESCE(m.associated_message_type, 0) = 0
GROUP BY h.id
ORDER BY MAX(m.date) DESC
LIMIT ?
`).all(n).map((raw) => {
const row = raw as { handle: string; last: string | null; messages: number; chat: string | null };
return { handle: row.handle, chat: row.chat, last: appleDateToIso(row.last), messages: Number(row.messages) };
}), path);
}
/** One file that arrived in a conversation. */
export interface IMessageAttachment {
/** The file's own name. */
name: string;
/** Where it sits on this Mac — `~/Library/Messages/Attachments/...`. PATHS
* ONLY: this verb never opens, copies or uploads a file. */
path: string | null;
/** What it is, as Messages recorded it. */
mime: string | null;
bytes: number | null;
date: string | null;
outgoing: boolean;
}
/** `attachments <contact>` — the files in one conversation, newest first. */
export function attachmentsFor(contact: string, limit: number, path?: string): ReadAnswer<IMessageAttachment[]> {
const n = clampLimit(limit);
return withDb((db) => db.prepare(`
SELECT COALESCE(a.transfer_name, a.filename) AS name,
a.filename AS path, a.mime_type AS mime, a.total_bytes AS bytes,
CAST(m.date AS TEXT) AS date, m.is_from_me AS is_from_me
FROM attachment a
JOIN message_attachment_join j ON j.attachment_id = a.ROWID
JOIN message m ON m.ROWID = j.message_id
LEFT JOIN handle h ON h.ROWID = m.handle_id
LEFT JOIN chat_message_join cj ON cj.message_id = m.ROWID
LEFT JOIN chat c ON c.ROWID = cj.chat_id
WHERE h.id = ? OR h.id LIKE ? OR c.chat_identifier = ? OR c.display_name LIKE ?
ORDER BY m.date DESC LIMIT ?
`).all(contact, `%${contact}%`, contact, `%${contact}%`, n).map((raw) => {
const row = raw as { name: string | null; path: string | null; mime: string | null;
bytes: number | null; date: string | null; is_from_me: number | null };
return {
name: row.name ?? (row.path?.split("/").pop() ?? "unnamed"),
path: row.path,
mime: row.mime,
bytes: row.bytes === null ? null : Number(row.bytes),
date: appleDateToIso(row.date),
outgoing: row.is_from_me === 1,
};
}), path);
}
/**
* snappy-imessage/chat-db.ts — THE READER, INSIDE THE SKILL.
*
* WHY THIS FILE EXISTS ⟨the owner, 2026-09-08 23:5x; measured 2026-09-09⟩.
* `readRecent` used to shell out to `/usr/bin/python3 ~/.snappy/imessage-read.py`
* — a file this skill does not ship, does not own, and cannot install. Measured
* on this Mac the same night: `~/.snappy/imessage-read.py` DOES NOT EXIST, so
* every read verb refused with "the reader is not installed" on the one machine
* that holds chat.db. A road that needs a stray file in someone's home
* directory is not three files, and the owner's rule is that anything can be.
*
* SO THE READER IS `node:sqlite`, WHICH IS IN NODE ITSELF (measured: node
* v25.8.1 here answers `require("node:sqlite")` with DatabaseSync, StatementSync,
* Session, constants, backup — no flag, no package, no build step). That is
* strictly better than the two roads it replaces: no python, no `better-sqlite3`
* native build (which CLAUDE.md records breaking on Homebrew node 26), and no
* second file. The `sqlite3(1)` CLI on this Mac is 3.43.2 and would also work,
* but a shell-out is a second road to the same data — banned by CLAUDE.md §4 —
* so it is not a fallback here. A runtime with no `node:sqlite` REFUSES BY NAME.
*
* ONE COMPUTER. This reads the chat.db of the Mac it runs on. See SKILL.md
* "Which Mac" — Messages is signed in on the Mini, and a Mac with no Messages
* account says so instead of answering an empty thread.
*/
import { accessSync, constants, existsSync } from "node:fs";
import { createRequire } from "node:module";
import { homedir } from "node:os";
import { join } from "node:path";
import { refuse, type Refusal } from "../snappy-settings/refusal-codes.ts";
/** This file is ESM, and `node:sqlite` must be reached in a way that CANNOT
* throw at module load: a runtime without it has to be able to LOAD this file
* in order to print the refusal that says so. A static `import` would kill the
* process before the refusal existed; a top-level `await import` would make
* every consumer async. `createRequire` is the one form that is lazy AND
* synchronous. */
const requireBuiltin = createRequire(import.meta.url);
/* ── WHERE THE DATABASE IS ─────────────────────────────────────────────── */
/** Messages' own store. `SNAPPY_IMESSAGE_DB` points the reader at a copy —
* that is how the tests run against a fixture database without Full Disk
* Access and without a single real message ever reaching this repo. */
export function chatDbPath(): string {
const override = process.env.SNAPPY_IMESSAGE_DB;
if (override !== undefined && override !== "") return override;
return join(homedir(), "Library", "Messages", "chat.db");
}
/* ── THE TWO REFUSALS A READ CAN OWE, EACH NAMING ITS OWN FIX ──────────── */
/** A refusal is a typed envelope, never a thrown stack. `outcome` is the
* discriminant — CLAUDE.md's rule that a falsy check over a typed failure is
* how a refusal gets read as an acceptance.
*
* IT IS THE COLLECTION'S ENVELOPE, NOT A SECOND ONE ⟨lane refusals-2,
* 2026-09-09⟩. This file used to spell its own four-code union — a second
* refusal table, which CLAUDE.md §4 bans outright: `no_full_disk_access`
* meant nothing to a caller who had enumerated the collection's words, and
* the envelope carried no `contract_slice`, so a reader could not find the
* slice of the contract that was violated. The CONDITIONS did not change;
* only the word each one is said with, and it now comes from the one table. */
export type ChatDbRefusal = Refusal;
/** THE ONE SENTENCE THE OWNER CAN ACT ON, written once because it is printed
* from three places and a fix that drifts between them is a fix nobody can
* follow. It names the PANE, not the concept. */
export const FDA_FIX =
"System Settings → Privacy & Security → Full Disk Access → add the app that runs this (Terminal, or SnappyOS), toggle it on, then quit and reopen that app and run this again.";
/** FULL DISK ACCESS IS THE REAL REQUIREMENT, and it is a MACHINE CAPABILITY,
* not a credential: there is no key to put in `.env.cache`, so it can never be
* a `requires` entry. macOS answers a denied read with EPERM at `access(2)` —
* before any open, in microseconds — which is what makes the sub-50 ms refusal
* possible at all. MEASURED on this Mac 2026-09-09: `accessSync(R_OK)` throws
* EPERM, `cp` says "Operation not permitted", the sqlite3 CLI says
* "authorization denied", and node:sqlite says "unable to open database file".
* Four different sentences for one cause; this function is the one that names
* the cause and the pane that fixes it. */
export function fullDiskAccessRefusal(path = chatDbPath()): ChatDbRefusal | null {
if (!existsSync(path)) {
// THE MAC ITSELF IS THE SERVICE. No credential and no grant fixes this:
// the Messages database is not on this machine, so the caller's move is to
// run the hand where it is — `service_unavailable`'s own condition.
return refuse(
"service_unavailable",
`There is no Messages database on this Mac: ${path} does not exist, so there are no iMessages here to read. Run this on the Mac that is signed in to Messages (the Mini); see snappy-imessage/SKILL.md, "Which Mac".`,
);
}
try {
accessSync(path, constants.R_OK);
return null;
} catch {
return refuse(
"capability_not_granted",
`This process cannot read ${path}. macOS protects Messages behind Full Disk Access, and this runner has not been granted it. ${FDA_FIX}`,
);
}
}
/* ── THE HANDLE ON THE DATABASE ────────────────────────────────────────── */
/** The shape of `node:sqlite`'s DatabaseSync that this file uses. Declared
* structurally so nothing here has to import the module at type level — a
* runtime without it must still be able to LOAD this file in order to print
* the refusal that says so. */
interface ReadableDb {
prepare(sql: string): { all(...params: unknown[]): unknown[] };
close(): void;
}
/** THE ONE OPEN. Read-only, and every failure it can have is a named refusal
* rather than a sqlite error string the reader has to guess at. */
export function openChatDb(path = chatDbPath()): { db: ReadableDb } | { refusal: ChatDbRefusal } {
const denied = fullDiskAccessRefusal(path);
if (denied !== null) return { refusal: denied };
let DatabaseSync: new (p: string, o?: Record<string, unknown>) => ReadableDb;
try {
({ DatabaseSync } = requireBuiltin("node:sqlite") as {
DatabaseSync: new (p: string, o?: Record<string, unknown>) => ReadableDb;
});
} catch {
return {
refusal: refuse(
"service_unavailable",
`This runtime has no node:sqlite, so nothing here can open a SQLite file. It is running ${process.version}. Run this hand on node 22.5 or newer (node:sqlite is built in; this Mac's node 25 has it) — there is no package to install.`,
),
};
}
try {
return { db: new DatabaseSync(path, { readOnly: true }) };
} catch (error) {
const text = error instanceof Error ? error.message : String(error);
if (/locked|busy/iu.test(text)) {
return {
refusal: refuse(
"precondition_failed",
`Messages is holding ${path} and would not let this read in: ${text}. Try again in a moment; if it persists, quit Messages once and run this again.`,
),
};
}
// The access(2) probe passed and the open still failed: on macOS that is
// the -wal/-shm sidecars being withheld, which is the SAME grant. Named as
// the same cause, so the fix printed is the one that actually works.
return {
refusal: refuse(
"capability_not_granted",
`This process could stat ${path} but could not open it (${text}) — macOS is withholding Messages' write-ahead log from this runner. ${FDA_FIX}`,
),
};
}
}
/* ── APPLE'S CLOCK ─────────────────────────────────────────────────────── */
/** Seconds between the Unix epoch and Apple's (2001-01-01T00:00:00Z). */
const APPLE_EPOCH_OFFSET_S = 978_307_200;
/** chat.db's `date`, as an ISO string. Two encodings live in one column: since
* macOS 10.13 it is NANOSECONDS since 2001-01-01, and before that it was
* SECONDS — a database that has been carried across upgrades holds both, so a
* reader that assumes one silently dates half the archive to 2001. The
* threshold is the only honest discriminant: a seconds value large enough to
* be a nanosecond count would be the year 33,000. Zero means "never" (an
* unset date_read), and answers null rather than 2001-01-01. */
export function appleDateToIso(raw: number | bigint | string | null | undefined): string | null {
if (raw === null || raw === undefined || raw === "") return null;
const value = typeof raw === "string" ? Number(raw) : typeof raw === "bigint" ? Number(raw) : raw;
if (!Number.isFinite(value) || value === 0) return null;
const seconds = Math.abs(value) > 1e11 ? value / 1e9 : value;
const ms = (seconds + APPLE_EPOCH_OFFSET_S) * 1000;
const date = new Date(ms);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
/* ── WHERE MODERN iMESSAGE KEEPS THE WORDS ─────────────────────────────── */
/** THE `text` COLUMN IS OFTEN NULL, and the message is in `attributedBody` —
* an Apple `typedstream` (NSArchiver) blob holding an NSAttributedString.
* This is why the retired python reader existed at all.
*
* WHAT THIS DECODES, EXACTLY: the single-NSString case, which is every plain
* message. After the archived class name `NSString` the stream carries a
* short header and then `+` (0x2B), which introduces the bytes: a length,
* then UTF-8. Lengths under 0x81 are the byte itself; 0x81 marks a 2-byte
* little-endian length, 0x82 a 4-byte one.
*
* WHAT IT DOES NOT DO, and answers null for rather than guessing: a blob with
* no NSString (an attachment-only message), and anything whose declared length
* runs off the end of the buffer. Null means "this hand could not read it",
* and the row still draws with its date, its sender and its attachments —
* a message with an honest empty body beats an invented one. The plain `text`
* column WINS whenever it is non-null; this runs only when it is not. */
export function decodeAttributedBody(blob: Uint8Array | Buffer | null | undefined): string | null {
if (blob === null || blob === undefined) return null;
const buf = Buffer.isBuffer(blob) ? blob : Buffer.from(blob);
if (buf.length === 0) return null;
const marker = buf.indexOf("NSString", 0, "latin1");
if (marker < 0) return null;
// The `+` that introduces the payload sits within a few bytes of the class
// name (Apple writes `\x01\x94\x84\x01+`). A bounded search, so a blob that
// merely mentions NSString elsewhere cannot drag the cursor across the file.
const windowEnd = Math.min(buf.length, marker + 8 + 16);
const plus = buf.indexOf(0x2b, marker + 8);
if (plus < 0 || plus >= windowEnd) return null;
let p = plus + 1;
if (p >= buf.length) return null;
let length: number;
if (buf[p] === 0x81) {
if (p + 3 > buf.length) return null;
length = buf.readUInt16LE(p + 1);
p += 3;
} else if (buf[p] === 0x82) {
if (p + 5 > buf.length) return null;
length = buf.readUInt32LE(p + 1);
p += 5;
} else {
length = buf[p];
p += 1;
}
if (length <= 0 || p + length > buf.length) return null;
const text = buf.subarray(p, p + length).toString("utf8");
return text === "" ? null : text;
}
/* ── TAPBACKS ──────────────────────────────────────────────────────────── */
/** A tapback is a MESSAGE ROW, not a property of the message it lands on —
* `associated_message_type` says which one and `associated_message_guid`
* points at the target. Left unmapped they draw as separate empty bubbles in
* the middle of a conversation, which is what Messages never shows. */
const TAPBACK: Record<number, string> = {
2000: "❤️", 2001: "👍", 2002: "👎", 2003: "😂", 2004: "‼️", 2005: "❓",
};
/** The emoji for an `associated_message_type`, or null when the row is an
* ordinary message (0) or a REMOVED tapback (3000-3005), which leaves nothing
* on the target and so is dropped rather than drawn. */
export function tapbackEmoji(associatedMessageType: number | null | undefined): string | null {
if (associatedMessageType === null || associatedMessageType === undefined) return null;
return TAPBACK[associatedMessageType] ?? null;
}
/** chat.db writes the target as `p:0/<guid>` or `bp:<guid>`; the guid is the
* part after the last `/` or `:`. */
export function tapbackTargetGuid(associatedMessageGuid: string | null | undefined): string | null {
if (typeof associatedMessageGuid !== "string" || associatedMessageGuid === "") return null;
const slash = associatedMessageGuid.lastIndexOf("/");
if (slash >= 0) return associatedMessageGuid.slice(slash + 1);
const colon = associatedMessageGuid.lastIndexOf(":");
return colon >= 0 ? associatedMessageGuid.slice(colon + 1) : associatedMessageGuid;
}
/* ── ONE MESSAGE, AS THIS SKILL READS IT ───────────────────────────────── */
export interface IMessageRow {
/** chat.db's own guid — one message, one id, whichever road read it. */
id: string;
/** The words. Null only when neither `text` nor `attributedBody` carried any
* (an attachment-only message), never an invented placeholder. */
text: string | null;
/** ISO 8601, from Apple's clock. */
date: string | null;
/** True when we sent it. */
outgoing: boolean;
/** The correspondent's handle (phone in E.164, or an Apple ID email). Null on
* our own messages, which is what the face wants. */
from: string | null;
/** Blue or green. chat.db spells it "iMessage" / "SMS". */
service: "imessage" | "sms" | null;
/** Only ever set on an outgoing message: nothing tells us whether THEY read
* ours except our own copy's date_read. */
receipt: "sent" | "delivered" | "read" | null;
/** A tapback someone left on this message. */
reaction: string | null;
/** How Messages titles the conversation this message sits in. */
chat: string | null;
/** File names only — see `attachments`. */
attachments: string[];
}
const MESSAGE_SELECT = `
SELECT m.ROWID AS rowid,
m.guid AS guid,
m.text AS text,
m.attributedBody AS attributed_body,
CAST(m.date AS TEXT) AS date,
CAST(m.date_read AS TEXT) AS date_read,
CAST(m.date_delivered AS TEXT) AS date_delivered,
m.is_from_me AS is_from_me,
m.is_sent AS is_sent,
m.is_delivered AS is_delivered,
m.service AS service,
m.associated_message_type AS associated_type,
m.associated_message_guid AS associated_guid,
h.id AS handle,
COALESCE(NULLIF(c.display_name, ''), c.chat_identifier) AS chat_name
FROM message m
LEFT JOIN handle h ON h.ROWID = m.handle_id
LEFT JOIN chat_message_join j ON j.message_id = m.ROWID
LEFT JOIN chat c ON c.ROWID = j.chat_id
WHERE COALESCE(m.associated_message_type, 0) = 0
`;
interface RawRow {
rowid: number; guid: string | null; text: string | null;
attributed_body: Uint8Array | null; date: string | null;
date_read: string | null; date_delivered: string | null;
is_from_me: number | null; is_sent: number | null; is_delivered: number | null;
service: string | null; associated_type: number | null; associated_guid: string | null;
handle: string | null; chat_name: string | null;
}
/** THE ONE ROW MAPPER. Every read below produces `IMessageRow` through this,
* so a column that is read one way in `recent` cannot be read another way in
* `search` (CLAUDE.md §4). */
function toRow(raw: RawRow, attachments: string[] = []): IMessageRow {
const outgoing = raw.is_from_me === 1;
const service = raw.service === null ? null
: raw.service.toLowerCase() === "sms" ? "sms" as const : "imessage" as const;
let receipt: IMessageRow["receipt"] = null;
if (outgoing) {
if (appleDateToIso(raw.date_read) !== null) receipt = "read";
else if (raw.is_delivered === 1 || appleDateToIso(raw.date_delivered) !== null) receipt = "delivered";
else if (raw.is_sent === 1) receipt = "sent";
}
return {
id: raw.guid ?? `rowid:${raw.rowid}`,
text: raw.text !== null && raw.text !== "" ? raw.text : decodeAttributedBody(raw.attributed_body),
date: appleDateToIso(raw.date),
outgoing,
from: outgoing ? null : raw.handle,
service,
receipt,
reaction: null,
chat: raw.chat_name,
attachments,
};
}
/** THE TAPBACKS ON A RUN OF MESSAGES, fetched by their targets rather than
* found among them. chat.db stores a tapback as its own message row, so when
* they shared the run's LIMIT the ask was spent on rows that never draw --
* MEASURED 2026-09-09: `thread --limit 3` answered ONE message, because two of
* the three newest rows were a tapback and its removal. The main SELECT now
* excludes them (`associated_message_type = 0`) and this asks for them by the
* guid each one points at.
*
* A REMOVAL CANCELS. chat.db keeps both the 2000-2005 row that added a tapback
* and the 3000-3005 row that took it away, so the LATEST row per target wins;
* a tapback someone removed must not still be on the message. */
function reactionsFor(db: ReadableDb, guids: string[]): Map<string, string> {
const out = new Map<string, string>();
if (guids.length === 0) return out;
// The three spellings chat.db uses for a target: `p:0/<guid>`, `bp:<guid>`,
// and the bare guid. Named explicitly so the join is an indexed equality
// rather than a LIKE scan over every message on the Mac.
const candidates = guids.flatMap((guid) => [`p:0/${guid}`, `bp:${guid}`, guid]);
const rows = db.prepare(`
SELECT associated_message_guid AS target, associated_message_type AS type
FROM message
WHERE associated_message_type BETWEEN 2000 AND 3005
AND associated_message_guid IN (${candidates.map(() => "?").join(",")})
ORDER BY date ASC
`).all(...candidates) as { target: string | null; type: number | null }[];
for (const row of rows) {
const guid = tapbackTargetGuid(row.target);
if (guid === null) continue;
const emoji = tapbackEmoji(row.type);
// Ascending date means a later row overwrites an earlier one; a removal
// (no emoji) deletes rather than writing null, so the map holds only what
// is still on the message.
if (emoji === null) out.delete(guid);
else out.set(guid, emoji);
}
return out;
}
/** Attachment file names for a run of messages, in one query rather than one
* per message. Names only — see `attachmentsFor` for the paths verb. */
function attachmentNames(db: ReadableDb, rowids: number[]): Map<number, string[]> {
const out = new Map<number, string[]>();
if (rowids.length === 0) return out;
const rows = db.prepare(`
SELECT j.message_id AS message_id,
COALESCE(a.transfer_name, a.filename) AS name
FROM message_attachment_join j
JOIN attachment a ON a.ROWID = j.attachment_id
WHERE j.message_id IN (${rowids.map(() => "?").join(",")})
`).all(...rowids) as { message_id: number; name: string | null }[];
for (const row of rows) {
if (row.name === null) continue;
const held = out.get(row.message_id) ?? [];
held.push(row.name.split("/").pop() ?? row.name);
out.set(row.message_id, held);
}
return out;
}
/** A run of raw rows finished into `IMessageRow`s, oldest first (how a
* conversation reads) with tapbacks folded in. */
function finish(db: ReadableDb, raws: RawRow[]): IMessageRow[] {
const names = attachmentNames(db, raws.map((raw) => raw.rowid));
const rows = raws.map((raw) => toRow(raw, names.get(raw.rowid) ?? []));
const reactions = reactionsFor(db, rows.map((row) => row.id));
for (const row of rows) row.reaction = reactions.get(row.id) ?? null;
return rows.reverse();
}
/** THE CEILING ON A READ. A hand that will answer 50,000 messages into a
* model's context is a hand that costs the caller their whole window; a hand
* that silently answers 10 when asked for 500 is lying. So the ask is clamped
* and the answer says what it clamped to. */
/** TWENTY, NOT TEN ⟨R17; the owner, 2026-09-09 01:5x: "20 not three"⟩. A read
* that answers ten messages of a live conversation teaches the reader the
* thread is shorter than it is, and the decision preview built on it shows a
* person a third of what they are answering. `max` is unchanged: this is a
* DEFAULT, and a caller that wants ten still asks for ten. */
export const LIMIT = { default: 20, max: 200 } as const;
export function clampLimit(asked: number | undefined): number {
if (asked === undefined || !Number.isFinite(asked) || asked <= 0) return LIMIT.default;
return Math.min(Math.floor(asked), LIMIT.max);
}
export type ReadAnswer<T> = { rows: T } | { refusal: ChatDbRefusal };
function withDb<T>(fn: (db: ReadableDb) => T, path?: string): ReadAnswer<T> {
const opened = openChatDb(path);
if ("refusal" in opened) return { refusal: opened.refusal };
try {
return { rows: fn(opened.db) };
} finally {
opened.db.close();
}
}
/** `recent [contact]` — the newest messages, across every conversation when no
* contact is named, or one person's when one is. */
export function readRecent(contact: string | undefined, limit: number, path?: string): ReadAnswer<IMessageRow[]> {
const n = clampLimit(limit);
return withDb((db) => {
const raws = contact === undefined
? db.prepare(`${MESSAGE_SELECT} ORDER BY m.date DESC LIMIT ?`).all(n) as RawRow[]
: db.prepare(`${MESSAGE_SELECT}
AND (h.id = ? OR h.id LIKE ? OR c.chat_identifier = ? OR c.display_name LIKE ?)
ORDER BY m.date DESC LIMIT ?`)
.all(contact, `%${contact}%`, contact, `%${contact}%`, n) as RawRow[];
return finish(db, raws);
}, path);
}
/** HOW FAR BACK A SEARCH DECODES. SQL can match the plain `text` column, but
* a modern message keeps its words in the `attributedBody` BLOB, which SQL
* cannot see into at all — measured against the fixture, a SQL-only search
* for a word that lives in a blob returns ZERO rows while the message is
* right there. So the second pass decodes recent blob-only rows in JS. The
* ceiling exists because the alternative is decoding a 40 MB archive on every
* search; a search that says how far it looked beats one that hangs. */
export const SEARCH_SCAN = 4000;
/** `search <text>` — messages whose words contain a phrase, case-insensitive.
*
* TWO PASSES, AND THE ANSWER SAYS SO. Pass one is SQL over `text`. Pass two
* decodes the newest `SEARCH_SCAN` messages whose `text` is NULL — the ones
* whose words are in a typedstream blob — and matches those in memory. A
* message older than that window and stored as a blob is NOT found, which is
* why `scanned` and `truncated` come back with the rows: a caller who needs
* the whole archive can see that this answer did not cover it. */
export function searchMessages(
phrase: string, limit: number, path?: string,
): ReadAnswer<{ rows: IMessageRow[]; scanned: number; truncated: boolean }> {
const n = clampLimit(limit);
const needle = phrase.toLowerCase();
return withDb((db) => {
const matched = db.prepare(
`${MESSAGE_SELECT} AND m.text LIKE ? ESCAPE '\\' ORDER BY m.date DESC LIMIT ?`,
).all(`%${phrase.replace(/[\\%_]/gu, (c) => `\\${c}`)}%`, n) as RawRow[];
const blobs = db.prepare(
`${MESSAGE_SELECT} AND m.text IS NULL AND m.attributedBody IS NOT NULL
ORDER BY m.date DESC LIMIT ?`,
).all(SEARCH_SCAN) as RawRow[];
const hits = blobs.filter((raw) => (decodeAttributedBody(raw.attributed_body) ?? "").toLowerCase().includes(needle));
// Newest first across both passes, then cut to the ask — so the two roads
// produce ONE ordered answer rather than a SQL block followed by a blob
// block, which is what a caller would have to re-sort themselves.
const both = [...matched, ...hits]
.sort((a, b) => Number(b.date ?? 0) - Number(a.date ?? 0))
.slice(0, n);
return { rows: finish(db, both), scanned: blobs.length, truncated: blobs.length >= SEARCH_SCAN };
}, path);
}
/** One correspondent as `contacts` answers them. */
export interface IMessageContact {
/** The handle Messages routes to: E.164, or an Apple ID email. */
handle: string;
/** How Messages titles the conversation, where it has a name for it. */
chat: string | null;
/** ISO of the newest message either way. */
last: string | null;
/** How many messages this Mac holds with them. */
messages: number;
}
/** `contacts` — the handles this Mac has messages with, busiest conversation
* first. NO ADDRESS BOOK IS READ: the names here are the ones Messages itself
* stored on the chat, and nothing is joined against Contacts.app, so this verb
* answers only what the person already put in this conversation. */
export function readContacts(limit: number, path?: string): ReadAnswer<IMessageContact[]> {
const n = clampLimit(limit);
return withDb((db) => db.prepare(`
SELECT h.id AS handle,
CAST(MAX(m.date) AS TEXT) AS last,
COUNT(*) AS messages,
MAX(NULLIF(c.display_name, '')) AS chat
FROM message m
JOIN handle h ON h.ROWID = m.handle_id
LEFT JOIN chat_message_join j ON j.message_id = m.ROWID
LEFT JOIN chat c ON c.ROWID = j.chat_id
WHERE COALESCE(m.associated_message_type, 0) = 0
GROUP BY h.id
ORDER BY MAX(m.date) DESC
LIMIT ?
`).all(n).map((raw) => {
const row = raw as { handle: string; last: string | null; messages: number; chat: string | null };
return { handle: row.handle, chat: row.chat, last: appleDateToIso(row.last), messages: Number(row.messages) };
}), path);
}
/** One file that arrived in a conversation. */
export interface IMessageAttachment {
/** The file's own name. */
name: string;
/** Where it sits on this Mac — `~/Library/Messages/Attachments/...`. PATHS
* ONLY: this verb never opens, copies or uploads a file. */
path: string | null;
/** What it is, as Messages recorded it. */
mime: string | null;
bytes: number | null;
date: string | null;
outgoing: boolean;
}
/** `attachments <contact>` — the files in one conversation, newest first. */
export function attachmentsFor(contact: string, limit: number, path?: string): ReadAnswer<IMessageAttachment[]> {
const n = clampLimit(limit);
return withDb((db) => db.prepare(`
SELECT COALESCE(a.transfer_name, a.filename) AS name,
a.filename AS path, a.mime_type AS mime, a.total_bytes AS bytes,
CAST(m.date AS TEXT) AS date, m.is_from_me AS is_from_me
FROM attachment a
JOIN message_attachment_join j ON j.attachment_id = a.ROWID
JOIN message m ON m.ROWID = j.message_id
LEFT JOIN handle h ON h.ROWID = m.handle_id
LEFT JOIN chat_message_join cj ON cj.message_id = m.ROWID
LEFT JOIN chat c ON c.ROWID = cj.chat_id
WHERE h.id = ? OR h.id LIKE ? OR c.chat_identifier = ? OR c.display_name LIKE ?
ORDER BY m.date DESC LIMIT ?
`).all(contact, `%${contact}%`, contact, `%${contact}%`, n).map((raw) => {
const row = raw as { name: string | null; path: string | null; mime: string | null;
bytes: number | null; date: string | null; is_from_me: number | null };
return {
name: row.name ?? (row.path?.split("/").pop() ?? "unnamed"),
path: row.path,
mime: row.mime,
bytes: row.bytes === null ? null : Number(row.bytes),
date: appleDateToIso(row.date),
outgoing: row.is_from_me === 1,
};
}), path);
}
{
"scenarios": [
{
"id": "read-a-thread-train",
"split": "train",
"intent": "Someone asks what a person texted. The hand must answer that person's conversation, not everybody's messages.",
"calls": [
"contacts --limit 5 --json",
"thread +15550100 --limit 10 --json"
],
"ground_truth": {
"rule": 13,
"first_call_needs_no_guess": true,
"kind": "imessage-thread",
"messages_are_one_correspondent": true,
"fields_filled_from_chat_db": ["service", "receipt", "reaction"]
}
},
{
"id": "bounded-read-train",
"split": "train",
"intent": "A count is a flag, and the answer is bounded by it. `recent 5` must refuse rather than file the 5 as a contact.",
"calls": [
"recent --limit 3 --json",
"recent +15550100 5"
],
"ground_truth": {
"rule": 17,
"limit_default": 10,
"limit_maximum": 200,
"second_call_refuses": true,
"refusal_names_the_flag": "--limit 5",
"limit_counts_messages_not_tapback_rows": true
}
},
{
"id": "refuse-without-full-disk-access-train",
"split": "train",
"intent": "On a Mac that has not granted Full Disk Access, every read refuses fast and names the System Settings pane instead of throwing a SQLite error.",
"calls": [
"recent --limit 3 --json",
"attachments +15550100 --json"
],
"ground_truth": {
"rule": 22,
"outcome": "refused",
"code": "no_full_disk_access",
"elapsed_ms_under": 50,
"child_processes": 0,
"fix_names_pane": "System Settings → Privacy & Security → Full Disk Access",
"never_an_empty_list": true
}
},
{
"id": "search-then-send-holdout",
"split": "holdout",
"intent": "Find a message by its words, then reply to the person who sent it. The search must reach words stored in an attributedBody archive, and the send must STAGE rather than reach the person.",
"calls": [
"search \"cut list\" --limit 5 --json",
"send +15550100 \"Got it, thanks\""
],
"ground_truth": {
"rule": 33,
"search_covers_attributed_body": true,
"search_reports_scanned_and_truncated": true,
"send_stages": true,
"send_did_not_reach_the_person": true,
"only_the_owners_own_number_sends_without_a_decision": true
}
},
{
"id": "attachment-only-message-holdout",
"split": "holdout",
"intent": "A message whose whole content is a file still draws: the face declares text as required, and the honest stand-in is the file's name, never a placeholder.",
"calls": [
"thread +15550100 --limit 20 --json",
"attachments +15550100 --limit 20 --json"
],
"ground_truth": {
"rule": 31,
"text_falls_back_to_attachment_name": true,
"attachments_are_paths_only": true,
"no_file_is_opened_or_uploaded": true
}
}
]
}
{
"scenarios": [
{
"id": "read-a-thread-train",
"split": "train",
"intent": "Someone asks what a person texted. The hand must answer that person's conversation, not everybody's messages.",
"calls": [
"contacts --limit 5 --json",
"thread +15550100 --limit 10 --json"
],
"ground_truth": {
"rule": 13,
"first_call_needs_no_guess": true,
"kind": "imessage-thread",
"messages_are_one_correspondent": true,
"fields_filled_from_chat_db": ["service", "receipt", "reaction"]
}
},
{
"id": "bounded-read-train",
"split": "train",
"intent": "A count is a flag, and the answer is bounded by it. `recent 5` must refuse rather than file the 5 as a contact.",
"calls": [
"recent --limit 3 --json",
"recent +15550100 5"
],
"ground_truth": {
"rule": 17,
"limit_default": 10,
"limit_maximum": 200,
"second_call_refuses": true,
"refusal_names_the_flag": "--limit 5",
"limit_counts_messages_not_tapback_rows": true
}
},
{
"id": "refuse-without-full-disk-access-train",
"split": "train",
"intent": "On a Mac that has not granted Full Disk Access, every read refuses fast and names the System Settings pane instead of throwing a SQLite error.",
"calls": [
"recent --limit 3 --json",
"attachments +15550100 --json"
],
"ground_truth": {
"rule": 22,
"outcome": "refused",
"code": "no_full_disk_access",
"elapsed_ms_under": 50,
"child_processes": 0,
"fix_names_pane": "System Settings → Privacy & Security → Full Disk Access",
"never_an_empty_list": true
}
},
{
"id": "search-then-send-holdout",
"split": "holdout",
"intent": "Find a message by its words, then reply to the person who sent it. The search must reach words stored in an attributedBody archive, and the send must STAGE rather than reach the person.",
"calls": [
"search \"cut list\" --limit 5 --json",
"send +15550100 \"Got it, thanks\""
],
"ground_truth": {
"rule": 33,
"search_covers_attributed_body": true,
"search_reports_scanned_and_truncated": true,
"send_stages": true,
"send_did_not_reach_the_person": true,
"only_the_owners_own_number_sends_without_a_decision": true
}
},
{
"id": "attachment-only-message-holdout",
"split": "holdout",
"intent": "A message whose whole content is a file still draws: the face declares text as required, and the honest stand-in is the file's name, never a placeholder.",
"calls": [
"thread +15550100 --limit 20 --json",
"attachments +15550100 --limit 20 --json"
],
"ground_truth": {
"rule": 31,
"text_falls_back_to_attachment_name": true,
"attachments_are_paths_only": true,
"no_file_is_opened_or_uploaded": true
}
}
]
}
/**
* THE READ SPEAKS THE FACE'S LANGUAGE — proved against the face's OWN schema,
* from a real chat.db.
*
* THE SCHEMA IS NEVER COPIED. `assertDrawsAs` loads the zod props the face
* itself declares (`snappy-faces/library/src/components/imessage-chat.tsx`)
* through the one road at `skills/hand-face-props.ts`.
*
* THE DATA COMES OUT OF THE READER, not out of a literal. An earlier version of
* this file hand-wrote `ts\tsender\ttext` lines and asserted on the parser —
* which proved the parser and nothing else. These rows are what `chat-db.ts`
* actually returns from a Messages-shaped database, so the test fails if the
* SQL, the date maths, the blob decoder or the face mapping drift.
*
* NOBODY REAL IS IN IT. Mara Quill and +1 555 0100 are invented; the owner's
* own conversations are never read here and never committed.
*/
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { assertCarriesActArguments, assertDrawsAs, assertDrawsInContext } from "../hand-face-props.ts";
import { HAND_CONTRACT, imessageDecisionFace, imessageFaceForVerb, imessageThreadFace } from "./api.ts";
import { LIMIT, readRecent } from "./chat-db.ts";
import { MARA, buildFixtureChatDb } from "./fixtures/build-chat-db.ts";
const DB = buildFixtureChatDb(join(mkdtempSync(join(tmpdir(), "snappy-imessage-face-")), "chat.db"));
function thread() {
const answer = readRecent(MARA, 10, DB);
assert.ok("rows" in answer, "the fixture read refused");
return answer.rows;
}
test("a thread draws as imessage-thread with every field the face declares", async () => {
const face = imessageThreadFace(thread(), "Mara Quill");
assert.equal(face.kind, "imessage-thread");
const drawn = await assertDrawsAs("imessage-thread", face);
// THE HEAD. A blank name over a real conversation was the visible defect.
assert.equal(drawn.chat, "Mara Quill");
assert.equal(drawn.total, 5);
const messages = drawn.messages as Record<string, unknown>[];
assert.equal(messages.length, 5);
// WHICH SIDE OF THE THREAD each message sits on. Without this every bubble
// drew on one side.
const incoming = messages.find((message) => message.id === "guid-1");
assert.equal(incoming?.outgoing, false);
assert.equal(incoming?.from, MARA);
assert.equal(incoming?.text, "Are you still coming by the workshop?");
// GREEN, because it is an SMS. The face draws service as the bubble colour,
// and the retired reader could not tell it from an iMessage.
assert.equal(incoming?.service, "sms");
const outgoing = messages.find((message) => message.id === "guid-2");
assert.equal(outgoing?.outgoing, true);
assert.equal(outgoing?.from, null);
assert.equal(outgoing?.service, "imessage");
// "Read" under the bubble, which the face renders ONLY from this field.
assert.equal(outgoing?.receipt, "read");
// The tapback rides on the message it was left on, not as a bubble.
assert.equal(messages.find((message) => message.id === "guid-4")?.reaction, "❤️");
// The words that live in the typedstream blob reach the face intact.
assert.match(String(messages.find((message) => message.id === "guid-3")?.text), /glue is still curing/);
// Every id is distinct, so the face draws five bubbles and not one.
assert.equal(new Set(messages.map((message) => message.id)).size, 5);
// Every date is ISO, which is what the face's stamp logic parses.
for (const message of messages) assert.match(String(message.date), /^\d{4}-\d{2}-\d{2}T/);
});
test("an attachment-only message draws with the file's name, never a placeholder", async () => {
// The face declares `text` as a REQUIRED string, so a message whose whole
// content is a file needs SOMETHING. Messages itself shows the file name.
const face = imessageThreadFace([{
id: "guid-a", text: null, date: "2026-09-04T15:20:00.000Z", outgoing: false,
from: MARA, service: "imessage", receipt: null, reaction: null,
chat: "Mara Quill", attachments: ["cut-list.pdf"],
}]);
const drawn = await assertDrawsAs("imessage-thread", face);
assert.equal((drawn.messages as Record<string, unknown>[])[0].text, "cut-list.pdf");
// And the head falls back to the row's own chat when none was named.
assert.equal(drawn.chat, "Mara Quill");
});
test("a message with neither words nor files still draws, with an empty body", async () => {
const face = imessageThreadFace([{
id: "guid-b", text: null, date: null, outgoing: true, from: null,
service: null, receipt: null, reaction: null, chat: null, attachments: [],
}]);
// It must VALIDATE — a row the face rejects takes the whole conversation
// down with it, which is worse than one empty bubble.
const drawn = await assertDrawsAs("imessage-thread", face);
assert.equal((drawn.messages as Record<string, unknown>[])[0].text, "");
});
test("a contactless recent takes NO face — several people are not one thread", () => {
const rows = thread();
assert.equal(imessageFaceForVerb("recent", undefined, rows), null);
// Named with a contact, both spellings answer the same face.
assert.equal(imessageFaceForVerb("recent", MARA, rows)?.kind, "imessage-thread");
assert.equal(imessageFaceForVerb("thread", MARA, rows)?.kind, "imessage-thread");
// A verb no iMessage face draws stays faceless.
assert.equal(imessageFaceForVerb("send", MARA, rows), null);
assert.equal(imessageFaceForVerb("contacts", MARA, rows), null);
});
/* ── THE DRAFT NEVER ARRIVES ALONE ⟨the owner's shape law, 2026-09-09 01:5x⟩ ──
*
* "For ANY message it should show the THREAD — WhatsApp, iMessage, Statechange,
* Gmail, comments, everything. You don't just show me the email you're going to
* send, you show it in the context; that is how you build trust."
*
* MEASURED before this: `send` answered `staged for approval: control …` and a
* person deciding whether to text Mara back had no way to see what she had
* said. chat.db was open on the same machine, two lines away in this file.
*
* THE ROWS ARE THE THREAD VERB'S OWN ROWS, read out of a real Messages-shaped
* database by `chat-db.ts` — so this fails if the SQL, the date maths, the blob
* decoder, the face mapping OR the composite drift.
*/
/** The rows put back into the thread face's own argument, so the composite can
* be proved as two faces. The imessage family spells it `messages`. */
const asIMessageThread = (rows: Record<string, unknown>[]) => ({ messages: rows, chat: "Mara Quill" });
test("a text arrives inside the conversation it answers", async () => {
const rows = thread();
const face = imessageDecisionFace({ act: { verb: "send", args: HAND_CONTRACT.verbs.send.args }, rows, to: MARA, body: "Yes — I will be there by four.", chat: "Mara Quill" });
// A CONVERSATION IS IN HAND, so the kind is the decision, not the composer.
assert.equal(face.kind, "imessage-decision");
assert.equal(face.threadKind, "imessage-thread");
assert.equal(face.threadTotal, 5);
const { draft, thread: drawn } = await assertDrawsInContext(face, asIMessageThread);
// THE CONTEXT IS THE POINT, and it is the SAME drawing `thread --json` gives:
// every field the face declares, off the real database.
assert.equal(drawn.length, 5);
const incoming = drawn.find((message) => message.id === "guid-1");
assert.equal(incoming?.text, "Are you still coming by the workshop?");
assert.equal(incoming?.outgoing, false);
assert.equal(incoming?.service, "sms");
// AND THE DRAFT IS THE ANSWER, addressed by the conversation's own name.
assert.equal(draft.to, "Mara Quill");
assert.equal(draft.body, "Yes — I will be there by four.");
// THE BUBBLE'S COLOUR IS A CLAIM ABOUT WHICH WIRE IT LEAVES ON, and it is
// read off the conversation's own MOST RECENT message rather than guessed.
// This thread opens on an SMS (guid-1) and ends on an iMessage (guid-4), so
// nothing is claimed: a green bubble here would tell the owner his words
// leave as a text when Messages will send an iMessage. Taking the FIRST
// service instead of the last is exactly how that lie gets written.
assert.equal(drawn[drawn.length - 1].service, "imessage");
assert.equal(draft.asSms, undefined);
assert.deepEqual(face.doors.map((d) => d.label), ["Send", "Later"]);
assert.equal(face.doors[0].primary, true);
assert.equal(face.doors[0].price, "sends the message to Mara Quill from this Mac now");
assert.deepEqual(face.doors.map((d) => d.verb), ["approved", "snoozed"]);
});
test("a first text to someone says so, and claims no colour it cannot know", async () => {
const face = imessageDecisionFace({ act: { verb: "send", args: HAND_CONTRACT.verbs.send.args }, rows: [], to: "+15555550199", body: "Hi — Robert. Is Thursday still good?" });
// NOT a decision kind: there is no conversation, and dressing a first message
// as an answer to one is the exact lie this road was built against.
assert.equal(face.kind, "imessage-compose");
assert.deepEqual(face.thread, []);
assert.equal(face.threadKind, null);
assert.equal(face.threadTotal, null);
const { draft } = await assertDrawsInContext(face, asIMessageThread);
assert.equal(draft.to, "+15555550199");
// NO `asSms`. With no conversation there is no service to read, and green
// would tell a person their words leave as a text when Messages may send an
// iMessage — a field that looks filled and is wrong.
assert.equal(draft.asSms, undefined);
});
test("the preview is declared, or the door refuses the flag that reaches it", () => {
assert.equal(HAND_CONTRACT.verbs.send.flags.json, "--json");
// A preview does not change what the verb IS: it still reaches a person.
assert.equal(HAND_CONTRACT.verbs.send.class, "send-to-a-person");
});
test("the reads ask for twenty, not ten", () => {
// "20 not three" — the owner, 2026-09-09 01:5x. Ten messages of a live
// conversation teaches the reader the thread is shorter than it is, and a
// decision built on it shows a person a third of what they are answering.
assert.equal(LIMIT.default, 20);
for (const verb of ["recent", "thread", "search", "contacts", "attachments"] as const) {
assert.equal(HAND_CONTRACT.verbs[verb].inputSchema.properties.limit.default, 20);
}
// The ceiling is unchanged: this is a default, not a new maximum.
assert.equal(LIMIT.max, 200);
});
test("a conversation that really is SMS draws green, and says so from its last word", async () => {
// The same five messages with the newest one arriving as SMS — which is what
// a person who has fallen off iMessage looks like in chat.db.
const rows = thread().map((row, index, all) => index === all.length - 1 ? { ...row, service: "sms" as const } : row);
const face = imessageDecisionFace({ act: { verb: "send", args: HAND_CONTRACT.verbs.send.args }, rows, to: MARA, body: "On my way.", chat: "Mara Quill" });
const { draft } = await assertDrawsInContext(face, asIMessageThread);
assert.equal(draft.asSms, true);
});
test("the preview carries every argument its own door's press would run", () => {
// RED FIRST ⟨lane doors-everywhere, 2026-09-09⟩: the draft named the person and carried no number, so a runner holding
// this preview and a primary door could not build the press at all. The
// check is the collection's shared one, read from the composite's own `act`
// against this hand's contract — never a list typed out beside it.
const face = imessageDecisionFace({ act: { verb: "send", args: HAND_CONTRACT.verbs.send.args }, rows: [], to: "+15555550199", body: "On my way." });
const act = assertCarriesActArguments(HAND_CONTRACT, face);
// `to` on the draft is the person's NAME when the conversation knows one;
// `phone` is the contract's word and the number the act really takes.
assert.equal(act.arguments.phone, "+15555550199");
assert.equal(act.arguments.text, "On my way.");
});
/**
* THE READ SPEAKS THE FACE'S LANGUAGE — proved against the face's OWN schema,
* from a real chat.db.
*
* THE SCHEMA IS NEVER COPIED. `assertDrawsAs` loads the zod props the face
* itself declares (`snappy-faces/library/src/components/imessage-chat.tsx`)
* through the one road at `skills/hand-face-props.ts`.
*
* THE DATA COMES OUT OF THE READER, not out of a literal. An earlier version of
* this file hand-wrote `ts\tsender\ttext` lines and asserted on the parser —
* which proved the parser and nothing else. These rows are what `chat-db.ts`
* actually returns from a Messages-shaped database, so the test fails if the
* SQL, the date maths, the blob decoder or the face mapping drift.
*
* NOBODY REAL IS IN IT. Mara Quill and +1 555 0100 are invented; the owner's
* own conversations are never read here and never committed.
*/
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { assertCarriesActArguments, assertDrawsAs, assertDrawsInContext } from "../hand-face-props.ts";
import { HAND_CONTRACT, imessageDecisionFace, imessageFaceForVerb, imessageThreadFace } from "./api.ts";
import { LIMIT, readRecent } from "./chat-db.ts";
import { MARA, buildFixtureChatDb } from "./fixtures/build-chat-db.ts";
const DB = buildFixtureChatDb(join(mkdtempSync(join(tmpdir(), "snappy-imessage-face-")), "chat.db"));
function thread() {
const answer = readRecent(MARA, 10, DB);
assert.ok("rows" in answer, "the fixture read refused");
return answer.rows;
}
test("a thread draws as imessage-thread with every field the face declares", async () => {
const face = imessageThreadFace(thread(), "Mara Quill");
assert.equal(face.kind, "imessage-thread");
const drawn = await assertDrawsAs("imessage-thread", face);
// THE HEAD. A blank name over a real conversation was the visible defect.
assert.equal(drawn.chat, "Mara Quill");
assert.equal(drawn.total, 5);
const messages = drawn.messages as Record<string, unknown>[];
assert.equal(messages.length, 5);
// WHICH SIDE OF THE THREAD each message sits on. Without this every bubble
// drew on one side.
const incoming = messages.find((message) => message.id === "guid-1");
assert.equal(incoming?.outgoing, false);
assert.equal(incoming?.from, MARA);
assert.equal(incoming?.text, "Are you still coming by the workshop?");
// GREEN, because it is an SMS. The face draws service as the bubble colour,
// and the retired reader could not tell it from an iMessage.
assert.equal(incoming?.service, "sms");
const outgoing = messages.find((message) => message.id === "guid-2");
assert.equal(outgoing?.outgoing, true);
assert.equal(outgoing?.from, null);
assert.equal(outgoing?.service, "imessage");
// "Read" under the bubble, which the face renders ONLY from this field.
assert.equal(outgoing?.receipt, "read");
// The tapback rides on the message it was left on, not as a bubble.
assert.equal(messages.find((message) => message.id === "guid-4")?.reaction, "❤️");
// The words that live in the typedstream blob reach the face intact.
assert.match(String(messages.find((message) => message.id === "guid-3")?.text), /glue is still curing/);
// Every id is distinct, so the face draws five bubbles and not one.
assert.equal(new Set(messages.map((message) => message.id)).size, 5);
// Every date is ISO, which is what the face's stamp logic parses.
for (const message of messages) assert.match(String(message.date), /^\d{4}-\d{2}-\d{2}T/);
});
test("an attachment-only message draws with the file's name, never a placeholder", async () => {
// The face declares `text` as a REQUIRED string, so a message whose whole
// content is a file needs SOMETHING. Messages itself shows the file name.
const face = imessageThreadFace([{
id: "guid-a", text: null, date: "2026-09-04T15:20:00.000Z", outgoing: false,
from: MARA, service: "imessage", receipt: null, reaction: null,
chat: "Mara Quill", attachments: ["cut-list.pdf"],
}]);
const drawn = await assertDrawsAs("imessage-thread", face);
assert.equal((drawn.messages as Record<string, unknown>[])[0].text, "cut-list.pdf");
// And the head falls back to the row's own chat when none was named.
assert.equal(drawn.chat, "Mara Quill");
});
test("a message with neither words nor files still draws, with an empty body", async () => {
const face = imessageThreadFace([{
id: "guid-b", text: null, date: null, outgoing: true, from: null,
service: null, receipt: null, reaction: null, chat: null, attachments: [],
}]);
// It must VALIDATE — a row the face rejects takes the whole conversation
// down with it, which is worse than one empty bubble.
const drawn = await assertDrawsAs("imessage-thread", face);
assert.equal((drawn.messages as Record<string, unknown>[])[0].text, "");
});
test("a contactless recent takes NO face — several people are not one thread", () => {
const rows = thread();
assert.equal(imessageFaceForVerb("recent", undefined, rows), null);
// Named with a contact, both spellings answer the same face.
assert.equal(imessageFaceForVerb("recent", MARA, rows)?.kind, "imessage-thread");
assert.equal(imessageFaceForVerb("thread", MARA, rows)?.kind, "imessage-thread");
// A verb no iMessage face draws stays faceless.
assert.equal(imessageFaceForVerb("send", MARA, rows), null);
assert.equal(imessageFaceForVerb("contacts", MARA, rows), null);
});
/* ── THE DRAFT NEVER ARRIVES ALONE ⟨the owner's shape law, 2026-09-09 01:5x⟩ ──
*
* "For ANY message it should show the THREAD — WhatsApp, iMessage, Statechange,
* Gmail, comments, everything. You don't just show me the email you're going to
* send, you show it in the context; that is how you build trust."
*
* MEASURED before this: `send` answered `staged for approval: control …` and a
* person deciding whether to text Mara back had no way to see what she had
* said. chat.db was open on the same machine, two lines away in this file.
*
* THE ROWS ARE THE THREAD VERB'S OWN ROWS, read out of a real Messages-shaped
* database by `chat-db.ts` — so this fails if the SQL, the date maths, the blob
* decoder, the face mapping OR the composite drift.
*/
/** The rows put back into the thread face's own argument, so the composite can
* be proved as two faces. The imessage family spells it `messages`. */
const asIMessageThread = (rows: Record<string, unknown>[]) => ({ messages: rows, chat: "Mara Quill" });
test("a text arrives inside the conversation it answers", async () => {
const rows = thread();
const face = imessageDecisionFace({ act: { verb: "send", args: HAND_CONTRACT.verbs.send.args }, rows, to: MARA, body: "Yes — I will be there by four.", chat: "Mara Quill" });
// A CONVERSATION IS IN HAND, so the kind is the decision, not the composer.
assert.equal(face.kind, "imessage-decision");
assert.equal(face.threadKind, "imessage-thread");
assert.equal(face.threadTotal, 5);
const { draft, thread: drawn } = await assertDrawsInContext(face, asIMessageThread);
// THE CONTEXT IS THE POINT, and it is the SAME drawing `thread --json` gives:
// every field the face declares, off the real database.
assert.equal(drawn.length, 5);
const incoming = drawn.find((message) => message.id === "guid-1");
assert.equal(incoming?.text, "Are you still coming by the workshop?");
assert.equal(incoming?.outgoing, false);
assert.equal(incoming?.service, "sms");
// AND THE DRAFT IS THE ANSWER, addressed by the conversation's own name.
assert.equal(draft.to, "Mara Quill");
assert.equal(draft.body, "Yes — I will be there by four.");
// THE BUBBLE'S COLOUR IS A CLAIM ABOUT WHICH WIRE IT LEAVES ON, and it is
// read off the conversation's own MOST RECENT message rather than guessed.
// This thread opens on an SMS (guid-1) and ends on an iMessage (guid-4), so
// nothing is claimed: a green bubble here would tell the owner his words
// leave as a text when Messages will send an iMessage. Taking the FIRST
// service instead of the last is exactly how that lie gets written.
assert.equal(drawn[drawn.length - 1].service, "imessage");
assert.equal(draft.asSms, undefined);
assert.deepEqual(face.doors.map((d) => d.label), ["Send", "Later"]);
assert.equal(face.doors[0].primary, true);
assert.equal(face.doors[0].price, "sends the message to Mara Quill from this Mac now");
assert.deepEqual(face.doors.map((d) => d.verb), ["approved", "snoozed"]);
});
test("a first text to someone says so, and claims no colour it cannot know", async () => {
const face = imessageDecisionFace({ act: { verb: "send", args: HAND_CONTRACT.verbs.send.args }, rows: [], to: "+15555550199", body: "Hi — Robert. Is Thursday still good?" });
// NOT a decision kind: there is no conversation, and dressing a first message
// as an answer to one is the exact lie this road was built against.
assert.equal(face.kind, "imessage-compose");
assert.deepEqual(face.thread, []);
assert.equal(face.threadKind, null);
assert.equal(face.threadTotal, null);
const { draft } = await assertDrawsInContext(face, asIMessageThread);
assert.equal(draft.to, "+15555550199");
// NO `asSms`. With no conversation there is no service to read, and green
// would tell a person their words leave as a text when Messages may send an
// iMessage — a field that looks filled and is wrong.
assert.equal(draft.asSms, undefined);
});
test("the preview is declared, or the door refuses the flag that reaches it", () => {
assert.equal(HAND_CONTRACT.verbs.send.flags.json, "--json");
// A preview does not change what the verb IS: it still reaches a person.
assert.equal(HAND_CONTRACT.verbs.send.class, "send-to-a-person");
});
test("the reads ask for twenty, not ten", () => {
// "20 not three" — the owner, 2026-09-09 01:5x. Ten messages of a live
// conversation teaches the reader the thread is shorter than it is, and a
// decision built on it shows a person a third of what they are answering.
assert.equal(LIMIT.default, 20);
for (const verb of ["recent", "thread", "search", "contacts", "attachments"] as const) {
assert.equal(HAND_CONTRACT.verbs[verb].inputSchema.properties.limit.default, 20);
}
// The ceiling is unchanged: this is a default, not a new maximum.
assert.equal(LIMIT.max, 200);
});
test("a conversation that really is SMS draws green, and says so from its last word", async () => {
// The same five messages with the newest one arriving as SMS — which is what
// a person who has fallen off iMessage looks like in chat.db.
const rows = thread().map((row, index, all) => index === all.length - 1 ? { ...row, service: "sms" as const } : row);
const face = imessageDecisionFace({ act: { verb: "send", args: HAND_CONTRACT.verbs.send.args }, rows, to: MARA, body: "On my way.", chat: "Mara Quill" });
const { draft } = await assertDrawsInContext(face, asIMessageThread);
assert.equal(draft.asSms, true);
});
test("the preview carries every argument its own door's press would run", () => {
// RED FIRST ⟨lane doors-everywhere, 2026-09-09⟩: the draft named the person and carried no number, so a runner holding
// this preview and a primary door could not build the press at all. The
// check is the collection's shared one, read from the composite's own `act`
// against this hand's contract — never a list typed out beside it.
const face = imessageDecisionFace({ act: { verb: "send", args: HAND_CONTRACT.verbs.send.args }, rows: [], to: "+15555550199", body: "On my way." });
const act = assertCarriesActArguments(HAND_CONTRACT, face);
// `to` on the draft is the person's NAME when the conversation knows one;
// `phone` is the contract's word and the number the act really takes.
assert.equal(act.arguments.phone, "+15555550199");
assert.equal(act.arguments.text, "On my way.");
});
// components/imessage-chat.tsx — MESSAGES' OWN SHAPES ⟨the owner, 2026-09-07:
// "it is NOT ONE FACE, it is MANY faces; even for one platform they have
// multiple faces"⟩.
//
// iMessage is the platform whose look people know best, because it is the one
// on the phone in their hand: the BLUE outgoing bubble, the GREY incoming one,
// the grey "Delivered" tucked under the last thing you sent. Drawing an
// iMessage read as our own card would be the most obviously wrong thing this
// library could do, so this file draws Messages as Messages.
//
// BLUE IS A FACT ABOUT THE ROUTE, NOT A THEME ⟨and it is the one thing everyone
// notices⟩. Apple's blue means iMessage; green means SMS. `service` carries it,
// and an unset service draws blue because that is what an iMessage read
// returns. Getting this backwards would tell a person their message went out
// over the wrong wire, which is a claim about the world, not a styling choice.
//
// "DELIVERED" IS A RECEIPT, and it obeys the same law the WhatsApp ticks and the
// Telegram checks obey: it renders ONLY from a declared `receipt`. A staged
// message has none and shows none. Messages prints it under the LAST outgoing
// bubble only — never under every one — which is why the thread computes that
// index rather than letting each bubble decide.
import type { JSX } from "react";
import { z } from "zod";
import { defineComponent } from "@openuidev/react-lang";
import "../../../snappy-faces/library/src/components/imessage-chat.css";
/** What Messages prints under the last thing you sent. */
export type IMessageReceipt = "sending" | "sent" | "delivered" | "read";
/** Blue is iMessage; green is SMS. The bubble's colour IS this value. */
export type IMessageService = "imessage" | "sms";
export interface IMessageEntry {
readonly id: string;
readonly text: string;
/** ISO, or a unix seconds/millis string. */
readonly date: string;
/** True when we sent it (Messages draws it right, in blue). */
readonly outgoing?: boolean | null;
/** Named only on an incoming GROUP message — Messages prints no name in a
* one-to-one thread, and never on your own bubble. */
readonly from?: string | null;
readonly service?: IMessageService | null;
readonly receipt?: IMessageReceipt | null;
/** A tapback, drawn as the small badge Messages sticks on the bubble. */
readonly reaction?: string | null;
}
function msOf(date: string): number {
if (/^\d+$/u.test(date)) return Number(date) * (date.length <= 10 ? 1000 : 1);
return Date.parse(date);
}
/** The stamp Messages floats above a run: "Today 3:29 PM", "Yesterday 8:04 AM". */
function stampOf(date: string, now: number): string {
const ms = msOf(date);
if (!Number.isFinite(ms)) return "";
const at = new Date(ms);
const clock = at.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
const startOf = (d: Date): number => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const days = Math.round((startOf(new Date(now)) - startOf(at)) / 86_400_000);
if (days === 0) return `Today ${clock}`;
if (days === 1) return `Yesterday ${clock}`;
if (days < 7) return `${at.toLocaleDateString(undefined, { weekday: "long" })} ${clock}`;
return `${at.toLocaleDateString(undefined, { month: "numeric", day: "numeric", year: "2-digit" })}, ${clock}`;
}
function receiptWords(receipt: IMessageReceipt): string {
if (receipt === "sending") return "Sending…";
if (receipt === "sent") return "Sent";
if (receipt === "read") return "Read";
return "Delivered";
}
// ── ONE BUBBLE ──────────────────────────────────────────────────────────────
export interface IMessageBubbleProps {
readonly text: string;
readonly outgoing?: boolean | null;
readonly from?: string | null;
readonly service?: IMessageService | null;
readonly receipt?: IMessageReceipt | null;
readonly reaction?: string | null;
/** The stamp above the bubble, when the caller wants one drawn. */
readonly stamp?: string | null;
/** Set inside a thread, which owns the window chrome around the run. */
readonly bare?: boolean;
}
export function IMessageBubbleView(props: IMessageBubbleProps): JSX.Element {
const out = props.outgoing !== false;
const sms = props.service === "sms";
const sender = (props.from ?? "").trim();
const body = (
<>
{props.stamp ? <div className="imsg-stamp">{props.stamp}</div> : null}
{!out && sender.length > 0 ? <div className="imsg-sender">{sender}</div> : null}
<div className="imsg-row" data-outgoing={out ? "true" : "false"}>
<div className="imsg-bubble" data-service={sms ? "sms" : "imessage"}>
{props.text}
{props.reaction ? <span className="imsg-tapback" aria-label="Tapback" role="img">{props.reaction}</span> : null}
</div>
</div>
{/* The receipt sits under the bubble, right-aligned, in Messages' small
grey — and only when the record actually carried one. */}
{out && props.receipt
? <div className="imsg-receipt" data-receipt={props.receipt}>{receiptWords(props.receipt)}</div>
: null}
</>
);
return props.bare === true
? body
: <div className="imsg-thread imsg-thread--single" data-channel="imessage-bubble"><div className="imsg-scroll">{body}</div></div>;
}
export const IMessageBubbleComponent = defineComponent({
name: "IMessageBubble",
description:
"USE FOR: one iMessage drawn as itself — 'show the text that came in', 'what he replied'. Channel-faithful: Apple's blue outgoing bubble on the right, grey incoming on the left, the tail, 'Delivered' underneath. Compact call: IMessageBubble(text). Optional and positional after text: outgoing (default true; false = received, grey, left), from (the sender's name — only on an incoming GROUP message), service ('imessage' = blue, 'sms' = green; blue is the default because that is what an iMessage read returns), receipt ('sending' | 'sent' | 'delivered' | 'read' — the grey line under the bubble renders ONLY from this; a staged message has none and shows none), reaction (a tapback emoji), stamp (the 'Today 3:29 PM' line above it).",
props: z.object({
text: z.string(),
outgoing: z.boolean().nullish(),
from: z.string().nullish(),
service: z.enum(["imessage", "sms"]).nullish(),
receipt: z.enum(["sending", "sent", "delivered", "read"]).nullish(),
reaction: z.string().nullish(),
stamp: z.string().nullish(),
}),
component: ({ props }): JSX.Element => <IMessageBubbleView {...props} />,
});
// ── A CONVERSATION ──────────────────────────────────────────────────────────
export interface IMessageThreadProps {
readonly messages: readonly IMessageEntry[];
/** Who the thread is with, as Messages titles it. */
readonly chat?: string | null;
readonly total?: number | null;
readonly now?: number;
}
/** Messages prints a time stamp when a gap opens, not on every bubble. */
const STAMP_GAP_MS = 60 * 60 * 1000;
export function IMessageThreadView({ messages, chat = null, total = null, now }: IMessageThreadProps): JSX.Element {
const at = now ?? Date.now();
const ordered = [...messages].sort((a, b) => msOf(a.date) - msOf(b.date));
const shown = ordered.length;
const title = chat ?? ordered.find((m) => m.outgoing !== true)?.from ?? "Messages";
const count = total !== null && total > shown ? `${shown} of ${total} messages` : `${shown} message${shown === 1 ? "" : "s"}`;
// ABSENT MEANS RECEIVED, IN A THREAD ⟨measured on the fixture, 2026-09-07⟩.
// `IMessageBubble` alone defaults to OUTGOING, because a lone bubble is
// almost always a message being staged. In a conversation that default is
// exactly backwards: a read returns the other person's messages with no
// `outgoing` field at all, so every one of them inherited "true" and the whole
// thread drew blue-on-the-right — one side of a conversation, rendered as if
// we had said all of it. The thread therefore resolves the flag ITSELF and
// hands the bubble an explicit boolean, rather than letting a default that is
// right in one context be silently wrong in the other.
const isOut = (m: IMessageEntry): boolean => m.outgoing === true;
// A ONE-TO-ONE THREAD NAMES NOBODY ⟨Messages' own rule⟩. Apple prints a
// sender's name above a bubble only in a GROUP, because in a two-person thread
// the name is already the window's title and repeating it on every incoming
// bubble is noise the real product does not have. A read carries `from` on
// every message regardless, so the face decides: if every incoming message is
// from the person the thread is titled after, this is a one-to-one and the
// names come off.
const senders = new Set(ordered.filter((m) => !isOut(m)).map((m) => (m.from ?? "").trim()).filter((s) => s.length > 0));
const isGroup = senders.size > 1 || (senders.size === 1 && chat !== null && !senders.has(chat.trim()));
// ONE RECEIPT, UNDER THE LAST THING WE SENT — Messages' own rule. Computing
// it here is why the bubble does not decide: a bubble cannot know it is last.
let lastOutgoing = -1;
for (const [i, m] of ordered.entries()) if (isOut(m)) lastOutgoing = i;
let previousMs = Number.NEGATIVE_INFINITY;
return (
<div className="imsg-thread" data-channel="imessage-thread" data-count={shown}>
<div className="imsg-bar">
<span className="imsg-bar__avatar" aria-hidden="true">{(title[0] ?? "?").toUpperCase()}</span>
<span className="imsg-bar__title">{title}</span>
<span className="imsg-bar__count">{count}</span>
</div>
<div className="imsg-scroll">
{shown === 0 ? <div className="imsg-empty">No messages in this read.</div> : null}
{ordered.map((message, i) => {
const ms = msOf(message.date);
const stamp = ms - previousMs > STAMP_GAP_MS ? stampOf(message.date, at) : null;
previousMs = Number.isFinite(ms) ? ms : previousMs;
return (
<IMessageBubbleView
key={message.id}
bare
text={message.text}
outgoing={isOut(message)}
from={isGroup ? message.from : null}
service={message.service}
receipt={i === lastOutgoing ? message.receipt : null}
reaction={message.reaction}
stamp={stamp}
/>
);
})}
</div>
</div>
);
}
const entryShape = z.object({
id: z.string(),
text: z.string(),
date: z.string(),
outgoing: z.boolean().nullish(),
from: z.string().nullish(),
service: z.enum(["imessage", "sms"]).nullish(),
receipt: z.enum(["sending", "sent", "delivered", "read"]).nullish(),
reaction: z.string().nullish(),
});
export const IMessageThreadComponent = defineComponent({
name: "IMessageThread",
description:
"USE FOR: 'what did the text thread say', 'show my messages with Dana', any iMessage or SMS READ. Channel-faithful conversation: Messages' white ground, the 'Today 3:29 PM' stamps where a gap opens, grey incoming bubbles left, blue outgoing right, and 'Delivered' under the LAST thing we sent only. Compact call: IMessageThread(messages, chat). Each message is {id, text, date, outgoing?, from?, service?, receipt?, reaction?}. Optional and positional after chat: total (when the read saw more than it shows).",
props: z.object({
messages: z.array(entryShape),
chat: z.string().nullish(),
total: z.number().nullish(),
}),
component: ({ props }): JSX.Element => (
<IMessageThreadView messages={props.messages} chat={props.chat} total={props.total} />
),
});
// components/imessage-chat.tsx — MESSAGES' OWN SHAPES ⟨the owner, 2026-09-07:
// "it is NOT ONE FACE, it is MANY faces; even for one platform they have
// multiple faces"⟩.
//
// iMessage is the platform whose look people know best, because it is the one
// on the phone in their hand: the BLUE outgoing bubble, the GREY incoming one,
// the grey "Delivered" tucked under the last thing you sent. Drawing an
// iMessage read as our own card would be the most obviously wrong thing this
// library could do, so this file draws Messages as Messages.
//
// BLUE IS A FACT ABOUT THE ROUTE, NOT A THEME ⟨and it is the one thing everyone
// notices⟩. Apple's blue means iMessage; green means SMS. `service` carries it,
// and an unset service draws blue because that is what an iMessage read
// returns. Getting this backwards would tell a person their message went out
// over the wrong wire, which is a claim about the world, not a styling choice.
//
// "DELIVERED" IS A RECEIPT, and it obeys the same law the WhatsApp ticks and the
// Telegram checks obey: it renders ONLY from a declared `receipt`. A staged
// message has none and shows none. Messages prints it under the LAST outgoing
// bubble only — never under every one — which is why the thread computes that
// index rather than letting each bubble decide.
import type { JSX } from "react";
import { z } from "zod";
import { defineComponent } from "@openuidev/react-lang";
import "../../../snappy-faces/library/src/components/imessage-chat.css";
/** What Messages prints under the last thing you sent. */
export type IMessageReceipt = "sending" | "sent" | "delivered" | "read";
/** Blue is iMessage; green is SMS. The bubble's colour IS this value. */
export type IMessageService = "imessage" | "sms";
export interface IMessageEntry {
readonly id: string;
readonly text: string;
/** ISO, or a unix seconds/millis string. */
readonly date: string;
/** True when we sent it (Messages draws it right, in blue). */
readonly outgoing?: boolean | null;
/** Named only on an incoming GROUP message — Messages prints no name in a
* one-to-one thread, and never on your own bubble. */
readonly from?: string | null;
readonly service?: IMessageService | null;
readonly receipt?: IMessageReceipt | null;
/** A tapback, drawn as the small badge Messages sticks on the bubble. */
readonly reaction?: string | null;
}
function msOf(date: string): number {
if (/^\d+$/u.test(date)) return Number(date) * (date.length <= 10 ? 1000 : 1);
return Date.parse(date);
}
/** The stamp Messages floats above a run: "Today 3:29 PM", "Yesterday 8:04 AM". */
function stampOf(date: string, now: number): string {
const ms = msOf(date);
if (!Number.isFinite(ms)) return "";
const at = new Date(ms);
const clock = at.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
const startOf = (d: Date): number => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const days = Math.round((startOf(new Date(now)) - startOf(at)) / 86_400_000);
if (days === 0) return `Today ${clock}`;
if (days === 1) return `Yesterday ${clock}`;
if (days < 7) return `${at.toLocaleDateString(undefined, { weekday: "long" })} ${clock}`;
return `${at.toLocaleDateString(undefined, { month: "numeric", day: "numeric", year: "2-digit" })}, ${clock}`;
}
function receiptWords(receipt: IMessageReceipt): string {
if (receipt === "sending") return "Sending…";
if (receipt === "sent") return "Sent";
if (receipt === "read") return "Read";
return "Delivered";
}
// ── ONE BUBBLE ──────────────────────────────────────────────────────────────
export interface IMessageBubbleProps {
readonly text: string;
readonly outgoing?: boolean | null;
readonly from?: string | null;
readonly service?: IMessageService | null;
readonly receipt?: IMessageReceipt | null;
readonly reaction?: string | null;
/** The stamp above the bubble, when the caller wants one drawn. */
readonly stamp?: string | null;
/** Set inside a thread, which owns the window chrome around the run. */
readonly bare?: boolean;
}
export function IMessageBubbleView(props: IMessageBubbleProps): JSX.Element {
const out = props.outgoing !== false;
const sms = props.service === "sms";
const sender = (props.from ?? "").trim();
const body = (
<>
{props.stamp ? <div className="imsg-stamp">{props.stamp}</div> : null}
{!out && sender.length > 0 ? <div className="imsg-sender">{sender}</div> : null}
<div className="imsg-row" data-outgoing={out ? "true" : "false"}>
<div className="imsg-bubble" data-service={sms ? "sms" : "imessage"}>
{props.text}
{props.reaction ? <span className="imsg-tapback" aria-label="Tapback" role="img">{props.reaction}</span> : null}
</div>
</div>
{/* The receipt sits under the bubble, right-aligned, in Messages' small
grey — and only when the record actually carried one. */}
{out && props.receipt
? <div className="imsg-receipt" data-receipt={props.receipt}>{receiptWords(props.receipt)}</div>
: null}
</>
);
return props.bare === true
? body
: <div className="imsg-thread imsg-thread--single" data-channel="imessage-bubble"><div className="imsg-scroll">{body}</div></div>;
}
export const IMessageBubbleComponent = defineComponent({
name: "IMessageBubble",
description:
"USE FOR: one iMessage drawn as itself — 'show the text that came in', 'what he replied'. Channel-faithful: Apple's blue outgoing bubble on the right, grey incoming on the left, the tail, 'Delivered' underneath. Compact call: IMessageBubble(text). Optional and positional after text: outgoing (default true; false = received, grey, left), from (the sender's name — only on an incoming GROUP message), service ('imessage' = blue, 'sms' = green; blue is the default because that is what an iMessage read returns), receipt ('sending' | 'sent' | 'delivered' | 'read' — the grey line under the bubble renders ONLY from this; a staged message has none and shows none), reaction (a tapback emoji), stamp (the 'Today 3:29 PM' line above it).",
props: z.object({
text: z.string(),
outgoing: z.boolean().nullish(),
from: z.string().nullish(),
service: z.enum(["imessage", "sms"]).nullish(),
receipt: z.enum(["sending", "sent", "delivered", "read"]).nullish(),
reaction: z.string().nullish(),
stamp: z.string().nullish(),
}),
component: ({ props }): JSX.Element => <IMessageBubbleView {...props} />,
});
// ── A CONVERSATION ──────────────────────────────────────────────────────────
export interface IMessageThreadProps {
readonly messages: readonly IMessageEntry[];
/** Who the thread is with, as Messages titles it. */
readonly chat?: string | null;
readonly total?: number | null;
readonly now?: number;
}
/** Messages prints a time stamp when a gap opens, not on every bubble. */
const STAMP_GAP_MS = 60 * 60 * 1000;
export function IMessageThreadView({ messages, chat = null, total = null, now }: IMessageThreadProps): JSX.Element {
const at = now ?? Date.now();
const ordered = [...messages].sort((a, b) => msOf(a.date) - msOf(b.date));
const shown = ordered.length;
const title = chat ?? ordered.find((m) => m.outgoing !== true)?.from ?? "Messages";
const count = total !== null && total > shown ? `${shown} of ${total} messages` : `${shown} message${shown === 1 ? "" : "s"}`;
// ABSENT MEANS RECEIVED, IN A THREAD ⟨measured on the fixture, 2026-09-07⟩.
// `IMessageBubble` alone defaults to OUTGOING, because a lone bubble is
// almost always a message being staged. In a conversation that default is
// exactly backwards: a read returns the other person's messages with no
// `outgoing` field at all, so every one of them inherited "true" and the whole
// thread drew blue-on-the-right — one side of a conversation, rendered as if
// we had said all of it. The thread therefore resolves the flag ITSELF and
// hands the bubble an explicit boolean, rather than letting a default that is
// right in one context be silently wrong in the other.
const isOut = (m: IMessageEntry): boolean => m.outgoing === true;
// A ONE-TO-ONE THREAD NAMES NOBODY ⟨Messages' own rule⟩. Apple prints a
// sender's name above a bubble only in a GROUP, because in a two-person thread
// the name is already the window's title and repeating it on every incoming
// bubble is noise the real product does not have. A read carries `from` on
// every message regardless, so the face decides: if every incoming message is
// from the person the thread is titled after, this is a one-to-one and the
// names come off.
const senders = new Set(ordered.filter((m) => !isOut(m)).map((m) => (m.from ?? "").trim()).filter((s) => s.length > 0));
const isGroup = senders.size > 1 || (senders.size === 1 && chat !== null && !senders.has(chat.trim()));
// ONE RECEIPT, UNDER THE LAST THING WE SENT — Messages' own rule. Computing
// it here is why the bubble does not decide: a bubble cannot know it is last.
let lastOutgoing = -1;
for (const [i, m] of ordered.entries()) if (isOut(m)) lastOutgoing = i;
let previousMs = Number.NEGATIVE_INFINITY;
return (
<div className="imsg-thread" data-channel="imessage-thread" data-count={shown}>
<div className="imsg-bar">
<span className="imsg-bar__avatar" aria-hidden="true">{(title[0] ?? "?").toUpperCase()}</span>
<span className="imsg-bar__title">{title}</span>
<span className="imsg-bar__count">{count}</span>
</div>
<div className="imsg-scroll">
{shown === 0 ? <div className="imsg-empty">No messages in this read.</div> : null}
{ordered.map((message, i) => {
const ms = msOf(message.date);
const stamp = ms - previousMs > STAMP_GAP_MS ? stampOf(message.date, at) : null;
previousMs = Number.isFinite(ms) ? ms : previousMs;
return (
<IMessageBubbleView
key={message.id}
bare
text={message.text}
outgoing={isOut(message)}
from={isGroup ? message.from : null}
service={message.service}
receipt={i === lastOutgoing ? message.receipt : null}
reaction={message.reaction}
stamp={stamp}
/>
);
})}
</div>
</div>
);
}
const entryShape = z.object({
id: z.string(),
text: z.string(),
date: z.string(),
outgoing: z.boolean().nullish(),
from: z.string().nullish(),
service: z.enum(["imessage", "sms"]).nullish(),
receipt: z.enum(["sending", "sent", "delivered", "read"]).nullish(),
reaction: z.string().nullish(),
});
export const IMessageThreadComponent = defineComponent({
name: "IMessageThread",
description:
"USE FOR: 'what did the text thread say', 'show my messages with Dana', any iMessage or SMS READ. Channel-faithful conversation: Messages' white ground, the 'Today 3:29 PM' stamps where a gap opens, grey incoming bubbles left, blue outgoing right, and 'Delivered' under the LAST thing we sent only. Compact call: IMessageThread(messages, chat). Each message is {id, text, date, outgoing?, from?, service?, receipt?, reaction?}. Optional and positional after chat: total (when the read saw more than it shows).",
props: z.object({
messages: z.array(entryShape),
chat: z.string().nullish(),
total: z.number().nullish(),
}),
component: ({ props }): JSX.Element => (
<IMessageThreadView messages={props.messages} chat={props.chat} total={props.total} />
),
});
/** families/imessage.tsx — THE IMESSAGE FAMILY, as its own chunk.
*
* Fetched from `ui://snappy/faces/imessage.js` the first time a imessage face is
* drawn, and never before ⟨`face-family.ts`, why the widget is no longer one
* file⟩. Every mount below forwards the payload to the view unchanged — the
* same one `createElement` the core applies to all of them, so a per-face arm
* here would restate a forwarding that already exists beside the component. */
import type { FaceFamilyModule } from "../../snappy-faces/face-family.ts";
import { IMessageBubbleView, IMessageThreadView } from "./components/imessage-chat.tsx";
import { IMessageComposeView } from "../../snappy-faces/library/src/components/chat-compose.tsx";
import { IMessageDecisionView } from "../../snappy-faces/library/src/components/chat-decision.tsx";
export const FAMILY: FaceFamilyModule = {
slug: "imessage",
mounts: {
"imessage-message": IMessageBubbleView,
"imessage-thread": IMessageThreadView,
"imessage-compose": IMessageComposeView,
"imessage-decision": IMessageDecisionView,
},
ownsItsDoors: ["imessage-decision"],
};
/** families/imessage.tsx — THE IMESSAGE FAMILY, as its own chunk.
*
* Fetched from `ui://snappy/faces/imessage.js` the first time a imessage face is
* drawn, and never before ⟨`face-family.ts`, why the widget is no longer one
* file⟩. Every mount below forwards the payload to the view unchanged — the
* same one `createElement` the core applies to all of them, so a per-face arm
* here would restate a forwarding that already exists beside the component. */
import type { FaceFamilyModule } from "../../snappy-faces/face-family.ts";
import { IMessageBubbleView, IMessageThreadView } from "./components/imessage-chat.tsx";
import { IMessageComposeView } from "../../snappy-faces/library/src/components/chat-compose.tsx";
import { IMessageDecisionView } from "../../snappy-faces/library/src/components/chat-decision.tsx";
export const FAMILY: FaceFamilyModule = {
slug: "imessage",
mounts: {
"imessage-message": IMessageBubbleView,
"imessage-thread": IMessageThreadView,
"imessage-compose": IMessageComposeView,
"imessage-decision": IMessageDecisionView,
},
ownsItsDoors: ["imessage-decision"],
};
{
"thread": {
"messages": [
{
"id": "t1",
"from": "Milo Fenwick",
"text": "Are you still coming by the workshop?",
"date": "2026-09-04T15:02:00Z"
},
{
"id": "t2",
"text": "Yes — leaving now.",
"date": "2026-09-04T15:04:00Z",
"outgoing": true
},
{
"id": "t3",
"from": "Milo Fenwick",
"text": "No rush, the glue is still curing.",
"date": "2026-09-04T15:05:00Z",
"reaction": "👍"
},
{
"id": "t4",
"text": "Running about ten minutes late, sorry!",
"date": "2026-09-04T15:29:00Z",
"outgoing": true,
"receipt": "read"
}
],
"chat": "Milo Fenwick",
"total": 18
},
"draft": {
"to": "Milo Fenwick",
"body": "Bench elevation is signed off — I will bring the printed set on Thursday."
}
}
{
"thread": {
"messages": [
{
"id": "t1",
"from": "Milo Fenwick",
"text": "Are you still coming by the workshop?",
"date": "2026-09-04T15:02:00Z"
},
{
"id": "t2",
"text": "Yes — leaving now.",
"date": "2026-09-04T15:04:00Z",
"outgoing": true
},
{
"id": "t3",
"from": "Milo Fenwick",
"text": "No rush, the glue is still curing.",
"date": "2026-09-04T15:05:00Z",
"reaction": "👍"
},
{
"id": "t4",
"text": "Running about ten minutes late, sorry!",
"date": "2026-09-04T15:29:00Z",
"outgoing": true,
"receipt": "read"
}
],
"chat": "Milo Fenwick",
"total": 18
},
"draft": {
"to": "Milo Fenwick",
"body": "Bench elevation is signed off — I will bring the printed set on Thursday."
}
}
{
"thread": {
"messages": [
{
"id": "t1",
"from": "Milo Fenwick",
"text": "Are you still coming by the workshop?",
"date": "2026-09-04T15:02:00Z"
},
{
"id": "t2",
"text": "Yes — leaving now.",
"date": "2026-09-04T15:04:00Z",
"outgoing": true
},
{
"id": "t3",
"from": "Milo Fenwick",
"text": "No rush, the glue is still curing.",
"date": "2026-09-04T15:05:00Z",
"reaction": "👍"
},
{
"id": "t4",
"text": "Running about ten minutes late, sorry!",
"date": "2026-09-04T15:29:00Z",
"outgoing": true,
"receipt": "read"
}
],
"chat": "Milo Fenwick",
"total": 18
},
"draft": {
"to": "Milo Fenwick",
"body": "Bench elevation is signed off — I will bring the printed set on Thursday.",
"waitingWords": "Waiting on you since 3:31 PM"
}
}
{
"thread": {
"messages": [
{
"id": "t1",
"from": "Milo Fenwick",
"text": "Are you still coming by the workshop?",
"date": "2026-09-04T15:02:00Z"
},
{
"id": "t2",
"text": "Yes — leaving now.",
"date": "2026-09-04T15:04:00Z",
"outgoing": true
},
{
"id": "t3",
"from": "Milo Fenwick",
"text": "No rush, the glue is still curing.",
"date": "2026-09-04T15:05:00Z",
"reaction": "👍"
},
{
"id": "t4",
"text": "Running about ten minutes late, sorry!",
"date": "2026-09-04T15:29:00Z",
"outgoing": true,
"receipt": "read"
}
],
"chat": "Milo Fenwick",
"total": 18
},
"draft": {
"to": "Milo Fenwick",
"body": "Bench elevation is signed off — I will bring the printed set on Thursday.",
"waitingWords": "Waiting on you since 3:31 PM"
}
}
{
"text": "Running about ten minutes late, sorry!",
"outgoing": true,
"service": "imessage",
"receipt": "delivered",
"stamp": "Today 3:29 PM"
}
{
"text": "Running about ten minutes late, sorry!",
"outgoing": true,
"service": "imessage",
"receipt": "delivered",
"stamp": "Today 3:29 PM"
}
{
"messages": [
{
"id": "t1",
"from": "Milo Fenwick",
"text": "Are you still coming by the workshop?",
"date": "2026-09-04T15:02:00Z"
},
{
"id": "t2",
"text": "Yes — leaving now.",
"date": "2026-09-04T15:04:00Z",
"outgoing": true
},
{
"id": "t3",
"from": "Milo Fenwick",
"text": "No rush, the glue is still curing.",
"date": "2026-09-04T15:05:00Z",
"reaction": "👍"
},
{
"id": "t4",
"text": "Running about ten minutes late, sorry!",
"date": "2026-09-04T15:29:00Z",
"outgoing": true,
"receipt": "read"
}
],
"chat": "Milo Fenwick",
"total": 18
}
{
"messages": [
{
"id": "t1",
"from": "Milo Fenwick",
"text": "Are you still coming by the workshop?",
"date": "2026-09-04T15:02:00Z"
},
{
"id": "t2",
"text": "Yes — leaving now.",
"date": "2026-09-04T15:04:00Z",
"outgoing": true
},
{
"id": "t3",
"from": "Milo Fenwick",
"text": "No rush, the glue is still curing.",
"date": "2026-09-04T15:05:00Z",
"reaction": "👍"
},
{
"id": "t4",
"text": "Running about ten minutes late, sorry!",
"date": "2026-09-04T15:29:00Z",
"outgoing": true,
"receipt": "read"
}
],
"chat": "Milo Fenwick",
"total": 18
}
/**
* snappy-imessage/fixtures/build-chat-db.ts — A CHAT.DB WITH NOBODY REAL IN IT.
*
* WHY IT IS BUILT AND NOT COMMITTED. The reader in `chat-db.ts` speaks SQL to
* Messages' own schema, and the only honest way to prove that SQL is to run it
* against that schema. But this Mac's real chat.db is the owner's private
* conversations: it is never copied, never committed, and (measured 2026-09-09)
* cannot even be opened here — Full Disk Access is denied to this runner.
*
* So the fixture is GENERATED at test time into a temp directory, with the
* collection's invented people (Mara Quill, +1 555 0100). It carries the exact
* columns and joins the reader names, including the two traps a hand-written
* fixture would miss: a message whose `text` is NULL and whose words live in an
* `attributedBody` typedstream blob, and a tapback row that must fold onto its
* target instead of drawing as an empty bubble.
*
* It is a FIXTURE OF THE SCHEMA, not of the data — if Messages changes a column
* name, this file and the reader disagree and the test goes red, which is what
* a fixture is for.
*/
import { createRequire } from "node:module";
const requireBuiltin = createRequire(import.meta.url);
/** Apple's clock: nanoseconds since 2001-01-01, which is what macOS ≥ 10.13
* writes. `appleDateToIso` also handles the older seconds encoding; the row
* built with `seconds: true` below is what proves it. */
function appleNs(iso: string): number {
return Math.round((new Date(iso).getTime() / 1000 - 978_307_200) * 1e9);
}
function appleSeconds(iso: string): number {
return Math.round(new Date(iso).getTime() / 1000 - 978_307_200);
}
/** A typedstream blob shaped the way Messages writes one, so the decoder is
* proved against the FORMAT rather than against its own assumptions. Layout:
* the archive header, the class name `NSString`, Apple's `\x01\x94\x84\x01`
* header bytes, `+`, a length, then UTF-8. Lengths of 0x81 or more take the
* two-byte little-endian form, which is the branch a long message exercises. */
export function typedStream(text: string): Buffer {
const body = Buffer.from(text, "utf8");
const head = Buffer.from("\x04\x0bstreamtyped\x81\xe8\x03\x84\x01\x40\x84\x84\x84", "latin1");
const cls = Buffer.from("NSString", "latin1");
const mid = Buffer.from([0x01, 0x94, 0x84, 0x01, 0x2b]);
const len = body.length < 0x81
? Buffer.from([body.length])
: Buffer.concat([Buffer.from([0x81]), (() => { const b = Buffer.alloc(2); b.writeUInt16LE(body.length); return b; })()]);
const tail = Buffer.from("\x86\x84\x02\x69\x49\x01\x00\x86", "latin1");
return Buffer.concat([head, cls, mid, len, body, tail]);
}
/** THE PEOPLE IN THE FIXTURE. Invented, and the same names the rest of the
* collection's fixtures use, so no read of this repo ever shows a real
* correspondent. */
export const MARA = "+15550100";
export const DEV = "dev@quillworks.example";
/** Build a Messages-shaped database at `path`. Returns the path. */
export function buildFixtureChatDb(path: string): string {
const { DatabaseSync } = requireBuiltin("node:sqlite") as {
DatabaseSync: new (p: string) => {
exec(sql: string): void;
prepare(sql: string): { run(...p: unknown[]): unknown };
close(): void;
};
};
const db = new DatabaseSync(path);
db.exec(`
CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT, service TEXT);
CREATE TABLE chat (ROWID INTEGER PRIMARY KEY, chat_identifier TEXT, display_name TEXT, service_name TEXT);
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY, guid TEXT, text TEXT, attributedBody BLOB,
handle_id INTEGER, service TEXT, date INTEGER, date_read INTEGER, date_delivered INTEGER,
is_from_me INTEGER, is_sent INTEGER, is_delivered INTEGER,
associated_message_type INTEGER, associated_message_guid TEXT
);
CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);
CREATE TABLE attachment (ROWID INTEGER PRIMARY KEY, filename TEXT, transfer_name TEXT, mime_type TEXT, total_bytes INTEGER);
CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);
`);
db.prepare("INSERT INTO handle (ROWID, id, service) VALUES (?,?,?)").run(1, MARA, "iMessage");
db.prepare("INSERT INTO handle (ROWID, id, service) VALUES (?,?,?)").run(2, DEV, "iMessage");
db.prepare("INSERT INTO chat (ROWID, chat_identifier, display_name, service_name) VALUES (?,?,?,?)")
.run(1, MARA, "Mara Quill", "iMessage");
db.prepare("INSERT INTO chat (ROWID, chat_identifier, display_name, service_name) VALUES (?,?,?,?)")
.run(2, DEV, "", "iMessage");
const message = db.prepare(`INSERT INTO message
(ROWID, guid, text, attributedBody, handle_id, service, date, date_read, date_delivered,
is_from_me, is_sent, is_delivered, associated_message_type, associated_message_guid)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
const join = db.prepare("INSERT INTO chat_message_join (chat_id, message_id) VALUES (?,?)");
// 1 — incoming, plain `text` column, an SMS (green).
message.run(1, "guid-1", "Are you still coming by the workshop?", null, 1, "SMS",
appleNs("2026-09-04T15:02:00Z"), 0, 0, 0, 0, 0, 0, null);
join.run(1, 1);
// 2 — OUTGOING, and READ: the receipt ladder's top rung.
message.run(2, "guid-2", "Yes — leaving now.", null, 1, "iMessage",
appleNs("2026-09-04T15:04:00Z"), appleNs("2026-09-04T15:05:30Z"), appleNs("2026-09-04T15:04:20Z"),
1, 1, 1, 0, null);
join.run(1, 2);
// 3 — THE TRAP: `text` IS NULL and the words are in the typedstream blob.
// This row is why the python reader existed. It is also long enough to
// take the two-byte length branch of the decoder.
const long = "No rush at all — the glue is still curing and I would rather it sat another hour than we rushed it and had to clamp the whole thing again tomorrow morning.";
message.run(3, "guid-3", null, typedStream(long), 1, "iMessage",
appleNs("2026-09-04T15:06:00Z"), 0, 0, 0, 0, 0, 0, null);
join.run(1, 3);
// 4 — outgoing with an ATTACHMENT, delivered but not read.
message.run(4, "guid-4", "Here's the cut list.", null, 1, "iMessage",
appleNs("2026-09-04T15:20:00Z"), 0, appleNs("2026-09-04T15:20:10Z"), 1, 1, 1, 0, null);
join.run(1, 4);
db.prepare("INSERT INTO attachment (ROWID, filename, transfer_name, mime_type, total_bytes) VALUES (?,?,?,?,?)")
.run(1, "~/Library/Messages/Attachments/ab/01/cut-list.pdf", "cut-list.pdf", "application/pdf", 51_234);
db.prepare("INSERT INTO message_attachment_join (message_id, attachment_id) VALUES (?,?)").run(4, 1);
// 5 — A TAPBACK on message 4. Not a bubble: it must fold onto guid-4.
message.run(5, "guid-5", null, null, 1, "iMessage",
appleNs("2026-09-04T15:21:00Z"), 0, 0, 0, 0, 0, 2000, "p:0/guid-4");
join.run(1, 5);
// 6 — A TAPBACK THAT WAS THEN TAKEN AWAY. chat.db keeps BOTH rows, so the
// add (here) and its removal (row 9) must cancel: guid-1 ends with no
// reaction. A reader that only looks at 2000-2005 leaves a thumbs-up on a
// message the person un-liked.
message.run(6, "guid-6", null, null, 1, "iMessage",
appleNs("2026-09-04T15:22:00Z"), 0, 0, 0, 0, 0, 2001, "p:0/guid-1");
join.run(1, 6);
// 7 — A SECOND correspondent, so `contacts` has more than one row and a
// contact-filtered read has something it must exclude.
message.run(7, "guid-7", "Deploy is green.", null, 2, "iMessage",
appleNs("2026-09-04T16:00:00Z"), 0, 0, 0, 0, 0, 0, null);
join.run(2, 7);
// 9 — the removal of row 6's thumbs-up, written later.
message.run(9, "guid-9", null, null, 1, "iMessage",
appleNs("2026-09-04T15:23:00Z"), 0, 0, 0, 0, 0, 3001, "p:0/guid-1");
join.run(1, 9);
// 8 — THE OLD CLOCK: a message written in the seconds encoding, from before
// macOS 10.13. A reader that assumes nanoseconds dates this to 2001.
message.run(8, "guid-8", "Archive message from the old encoding.", null, 1, "iMessage",
appleSeconds("2016-03-02T09:15:00Z"), 0, 0, 0, 0, 0, 0, null);
join.run(1, 8);
db.close();
return path;
}
/**
* snappy-imessage/fixtures/build-chat-db.ts — A CHAT.DB WITH NOBODY REAL IN IT.
*
* WHY IT IS BUILT AND NOT COMMITTED. The reader in `chat-db.ts` speaks SQL to
* Messages' own schema, and the only honest way to prove that SQL is to run it
* against that schema. But this Mac's real chat.db is the owner's private
* conversations: it is never copied, never committed, and (measured 2026-09-09)
* cannot even be opened here — Full Disk Access is denied to this runner.
*
* So the fixture is GENERATED at test time into a temp directory, with the
* collection's invented people (Mara Quill, +1 555 0100). It carries the exact
* columns and joins the reader names, including the two traps a hand-written
* fixture would miss: a message whose `text` is NULL and whose words live in an
* `attributedBody` typedstream blob, and a tapback row that must fold onto its
* target instead of drawing as an empty bubble.
*
* It is a FIXTURE OF THE SCHEMA, not of the data — if Messages changes a column
* name, this file and the reader disagree and the test goes red, which is what
* a fixture is for.
*/
import { createRequire } from "node:module";
const requireBuiltin = createRequire(import.meta.url);
/** Apple's clock: nanoseconds since 2001-01-01, which is what macOS ≥ 10.13
* writes. `appleDateToIso` also handles the older seconds encoding; the row
* built with `seconds: true` below is what proves it. */
function appleNs(iso: string): number {
return Math.round((new Date(iso).getTime() / 1000 - 978_307_200) * 1e9);
}
function appleSeconds(iso: string): number {
return Math.round(new Date(iso).getTime() / 1000 - 978_307_200);
}
/** A typedstream blob shaped the way Messages writes one, so the decoder is
* proved against the FORMAT rather than against its own assumptions. Layout:
* the archive header, the class name `NSString`, Apple's `\x01\x94\x84\x01`
* header bytes, `+`, a length, then UTF-8. Lengths of 0x81 or more take the
* two-byte little-endian form, which is the branch a long message exercises. */
export function typedStream(text: string): Buffer {
const body = Buffer.from(text, "utf8");
const head = Buffer.from("\x04\x0bstreamtyped\x81\xe8\x03\x84\x01\x40\x84\x84\x84", "latin1");
const cls = Buffer.from("NSString", "latin1");
const mid = Buffer.from([0x01, 0x94, 0x84, 0x01, 0x2b]);
const len = body.length < 0x81
? Buffer.from([body.length])
: Buffer.concat([Buffer.from([0x81]), (() => { const b = Buffer.alloc(2); b.writeUInt16LE(body.length); return b; })()]);
const tail = Buffer.from("\x86\x84\x02\x69\x49\x01\x00\x86", "latin1");
return Buffer.concat([head, cls, mid, len, body, tail]);
}
/** THE PEOPLE IN THE FIXTURE. Invented, and the same names the rest of the
* collection's fixtures use, so no read of this repo ever shows a real
* correspondent. */
export const MARA = "+15550100";
export const DEV = "dev@quillworks.example";
/** Build a Messages-shaped database at `path`. Returns the path. */
export function buildFixtureChatDb(path: string): string {
const { DatabaseSync } = requireBuiltin("node:sqlite") as {
DatabaseSync: new (p: string) => {
exec(sql: string): void;
prepare(sql: string): { run(...p: unknown[]): unknown };
close(): void;
};
};
const db = new DatabaseSync(path);
db.exec(`
CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT, service TEXT);
CREATE TABLE chat (ROWID INTEGER PRIMARY KEY, chat_identifier TEXT, display_name TEXT, service_name TEXT);
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY, guid TEXT, text TEXT, attributedBody BLOB,
handle_id INTEGER, service TEXT, date INTEGER, date_read INTEGER, date_delivered INTEGER,
is_from_me INTEGER, is_sent INTEGER, is_delivered INTEGER,
associated_message_type INTEGER, associated_message_guid TEXT
);
CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);
CREATE TABLE attachment (ROWID INTEGER PRIMARY KEY, filename TEXT, transfer_name TEXT, mime_type TEXT, total_bytes INTEGER);
CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);
`);
db.prepare("INSERT INTO handle (ROWID, id, service) VALUES (?,?,?)").run(1, MARA, "iMessage");
db.prepare("INSERT INTO handle (ROWID, id, service) VALUES (?,?,?)").run(2, DEV, "iMessage");
db.prepare("INSERT INTO chat (ROWID, chat_identifier, display_name, service_name) VALUES (?,?,?,?)")
.run(1, MARA, "Mara Quill", "iMessage");
db.prepare("INSERT INTO chat (ROWID, chat_identifier, display_name, service_name) VALUES (?,?,?,?)")
.run(2, DEV, "", "iMessage");
const message = db.prepare(`INSERT INTO message
(ROWID, guid, text, attributedBody, handle_id, service, date, date_read, date_delivered,
is_from_me, is_sent, is_delivered, associated_message_type, associated_message_guid)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
const join = db.prepare("INSERT INTO chat_message_join (chat_id, message_id) VALUES (?,?)");
// 1 — incoming, plain `text` column, an SMS (green).
message.run(1, "guid-1", "Are you still coming by the workshop?", null, 1, "SMS",
appleNs("2026-09-04T15:02:00Z"), 0, 0, 0, 0, 0, 0, null);
join.run(1, 1);
// 2 — OUTGOING, and READ: the receipt ladder's top rung.
message.run(2, "guid-2", "Yes — leaving now.", null, 1, "iMessage",
appleNs("2026-09-04T15:04:00Z"), appleNs("2026-09-04T15:05:30Z"), appleNs("2026-09-04T15:04:20Z"),
1, 1, 1, 0, null);
join.run(1, 2);
// 3 — THE TRAP: `text` IS NULL and the words are in the typedstream blob.
// This row is why the python reader existed. It is also long enough to
// take the two-byte length branch of the decoder.
const long = "No rush at all — the glue is still curing and I would rather it sat another hour than we rushed it and had to clamp the whole thing again tomorrow morning.";
message.run(3, "guid-3", null, typedStream(long), 1, "iMessage",
appleNs("2026-09-04T15:06:00Z"), 0, 0, 0, 0, 0, 0, null);
join.run(1, 3);
// 4 — outgoing with an ATTACHMENT, delivered but not read.
message.run(4, "guid-4", "Here's the cut list.", null, 1, "iMessage",
appleNs("2026-09-04T15:20:00Z"), 0, appleNs("2026-09-04T15:20:10Z"), 1, 1, 1, 0, null);
join.run(1, 4);
db.prepare("INSERT INTO attachment (ROWID, filename, transfer_name, mime_type, total_bytes) VALUES (?,?,?,?,?)")
.run(1, "~/Library/Messages/Attachments/ab/01/cut-list.pdf", "cut-list.pdf", "application/pdf", 51_234);
db.prepare("INSERT INTO message_attachment_join (message_id, attachment_id) VALUES (?,?)").run(4, 1);
// 5 — A TAPBACK on message 4. Not a bubble: it must fold onto guid-4.
message.run(5, "guid-5", null, null, 1, "iMessage",
appleNs("2026-09-04T15:21:00Z"), 0, 0, 0, 0, 0, 2000, "p:0/guid-4");
join.run(1, 5);
// 6 — A TAPBACK THAT WAS THEN TAKEN AWAY. chat.db keeps BOTH rows, so the
// add (here) and its removal (row 9) must cancel: guid-1 ends with no
// reaction. A reader that only looks at 2000-2005 leaves a thumbs-up on a
// message the person un-liked.
message.run(6, "guid-6", null, null, 1, "iMessage",
appleNs("2026-09-04T15:22:00Z"), 0, 0, 0, 0, 0, 2001, "p:0/guid-1");
join.run(1, 6);
// 7 — A SECOND correspondent, so `contacts` has more than one row and a
// contact-filtered read has something it must exclude.
message.run(7, "guid-7", "Deploy is green.", null, 2, "iMessage",
appleNs("2026-09-04T16:00:00Z"), 0, 0, 0, 0, 0, 0, null);
join.run(2, 7);
// 9 — the removal of row 6's thumbs-up, written later.
message.run(9, "guid-9", null, null, 1, "iMessage",
appleNs("2026-09-04T15:23:00Z"), 0, 0, 0, 0, 0, 3001, "p:0/guid-1");
join.run(1, 9);
// 8 — THE OLD CLOCK: a message written in the seconds encoding, from before
// macOS 10.13. A reader that assumes nanoseconds dates this to 2001.
message.run(8, "guid-8", "Archive message from the old encoding.", null, 1, "iMessage",
appleSeconds("2016-03-02T09:15:00Z"), 0, 0, 0, 0, 0, 0, null);
join.run(1, 8);
db.close();
return path;
}
imsg CLI Reference#Complete reference for the imsg command-line tool installed on Robert's Mac Mini. All commands run via ssh macmini 'imsg ...' from any other machine. Internally, imsg reads/writes the SQLite database backing Apple's Messages.app and uses AppleScript to trigger sends.
bash# Aliases (in ~/.ssh/config on Robert's machines)
ssh macmini # robertboulos@Roberts-Mac-mini.local
ssh robertboulos@Roberts-Mac-mini.local # explicit
ssh robertboulos@<tailscale-ip> # via Tailscale (off-LAN)
# Test
ssh macmini 'echo ok && which imsg'
If which imsg returns nothing, the binary is not on PATH for non-interactive sessions. Try absolute paths in this order: /usr/local/bin/imsg, /opt/homebrew/bin/imsg, ~/bin/imsg.
imsg send#Send a new message or reply to an existing conversation.
imsg send [--to PHONE | --chat-id ID] --text "MESSAGE" [--file PATH] [--service iMessage|SMS]
Flags:
| Flag | Required | Description |
|---|---|---|
--to PHONE |
one of | E.164 phone (+14155551212) or email-as-Apple-ID |
--chat-id ID |
one of | Numeric chat ID from imsg chats (use for groups & continuity) |
--text "MSG" |
yes | Message body. Escape single quotes as '\'' inside SSH single quotes |
--file PATH |
no | Absolute path to file on Mac Mini to attach |
--service |
no | Default: iMessage. Use SMS to force green-bubble (requires SMS Forwarding paired iPhone) |
Examples:
bash# Simple text by phone
ssh macmini 'imsg send --to +14155551212 --text "Hey!"'
# Reply to existing 1:1 thread (preserves blue/green continuity)
ssh macmini 'imsg send --chat-id 47 --text "Got it"'
# Reply to group chat
ssh macmini 'imsg send --chat-id 92 --text "On it!"'
# With image attachment
ssh macmini 'imsg send --to +14155551212 --text "Screenshot" --file /tmp/screen.png'
# With doc attachment
ssh macmini 'imsg send --to +14155551212 --text "Contract" --file ~/Documents/contract.pdf'
# Force SMS (green bubble) -- requires Continuity SMS Forwarding
ssh macmini 'imsg send --to +14155551212 --text "Test SMS" --service SMS'
# Single quote inside the message
ssh macmini 'imsg send --to +14155551212 --text "It'\''s done"'
# Multi-line message (newlines preserved)
ssh macmini 'imsg send --to +14155551212 --text "Line 1
Line 2
Line 3"'
Exit codes:
0 -- sent (no delivery confirmation)1 -- invalid flags2 -- Messages.app not running / not signed in3 -- file not found (attachment)4 -- invalid phone number formatimsg history#Read past messages from a conversation.
imsg history [--chat-id ID | --to PHONE] [--limit N] [--start ISO8601] [--end ISO8601] [--json]
Flags:
| Flag | Description |
|---|---|
--chat-id ID |
Read by chat ID |
--to PHONE |
Read 1:1 conversation by phone number |
--limit N |
Max messages to return (default: 20) |
--start TS |
ISO8601 start timestamp |
--end TS |
ISO8601 end timestamp |
--json |
Output JSON instead of human-readable |
Examples:
bash# Last 10 messages from a chat
ssh macmini 'imsg history --chat-id 59 --limit 10 --json'
# Today's messages from a contact
ssh macmini 'imsg history --to +14155551212 --start 2026-04-07T00:00:00Z --json'
# Date range
ssh macmini 'imsg history --chat-id 47 --start 2026-04-01T00:00:00Z --end 2026-04-07T23:59:59Z --json'
# Pipe to jq for processing
ssh macmini 'imsg history --chat-id 59 --limit 1 --json' | jq -r '.[0].text'
# Extract a 6-digit verification code from the latest SMS
ssh macmini 'imsg history --chat-id 59 --limit 1 --json' | jq -r '.[0].text' | grep -oE '[0-9]{6}'
JSON record shape:
json{
"id": 12345,
"chat_id": 59,
"from": "+16473608487",
"from_me": false,
"text": "Your Slack code is 482919",
"service": "SMS",
"date": "2026-04-07T14:23:11Z",
"attachments": [],
"is_read": true
}
imsg chats#List all known conversations with their numeric IDs.
imsg chats [--json] [--include-archived]
bash# Human-readable
ssh macmini 'imsg chats'
# JSON
ssh macmini 'imsg chats --json'
# Find a specific group by member
ssh macmini 'imsg chats --json' | jq '.[] | select(.participants | tostring | contains("+14155551212"))'
JSON record shape:
json{
"chat_id": 92,
"display_name": "Snappy Team",
"participants": ["+14155551212", "+16473608487"],
"is_group": true,
"service": "iMessage",
"last_message_at": "2026-04-07T11:42:00Z",
"unread_count": 0
}
imsg watch#Stream new incoming messages to stdout. Blocks until killed.
imsg watch [--chat-id ID] [--json]
bash# Watch all incoming
ssh macmini 'imsg watch --json'
# Watch one chat
ssh macmini 'imsg watch --chat-id 59 --json'
# Background watch with timeout (for SMS code retrieval)
ssh macmini 'imsg watch --chat-id 59 --json' &
WATCH_PID=$!
sleep 30
kill $WATCH_PID 2>/dev/null
Each line of output is one JSON record matching the imsg history shape.
| Mode | Trigger | Use For |
|---|---|---|
| Human | default | Quick eye-checks during interactive SSH |
| JSON | --json |
All scripted/automated reads -- always use this in skills |
| Code | Meaning | Recovery |
|---|---|---|
| 0 | Success | -- |
| 1 | Invalid CLI flags | Check syntax, re-run |
| 2 | Messages.app not signed in / iMessage offline | Robert opens Messages.app on Mac Mini, re-auths Apple ID |
| 3 | Attachment file not found | Verify path on Mac Mini, scp first if needed |
| 4 | Invalid phone format | Convert to E.164 (+1XXXXXXXXXX) |
| 5 | Database lock (rare) | Retry after 1-2 sec |
| 6 | Permission denied (Full Disk Access) | macOS System Settings → Privacy → Full Disk Access → enable for Terminal/sshd |
When imsg is unavailable, drop to raw AppleScript via osascript:
bash# Send iMessage by phone
ssh macmini 'osascript <<APPLESCRIPT
tell application "Messages"
set targetService to 1st account whose service type = iMessage
set targetBuddy to participant "+14155551212" of targetService
send "Your message here" to targetBuddy
end tell
APPLESCRIPT
'
# Send SMS (requires Continuity SMS Forwarding)
ssh macmini 'osascript <<APPLESCRIPT
tell application "Messages"
set targetService to 1st account whose service type = SMS
set targetBuddy to participant "+14155551212" of targetService
send "Your SMS here" to targetBuddy
end tell
APPLESCRIPT
'
# Send to existing chat by display name
ssh macmini 'osascript <<APPLESCRIPT
tell application "Messages"
set targetChat to chat "Snappy Team"
send "Hi team" to targetChat
end tell
APPLESCRIPT
'
Limitations of osascript fallback:
iMessage vs SMS may need manual specificationIf both imsg and osascript are unavailable, the Messages database is at:
~/Library/Messages/chat.db
bash# READ-ONLY query -- never write directly, you'll corrupt Messages.app state
ssh macmini 'sqlite3 -readonly ~/Library/Messages/chat.db "SELECT id, text, date FROM message ORDER BY date DESC LIMIT 5;"'
Warnings:
sqlite3date / 1e9 + 978307200chat.db directly. Use imsg send or osascript to send.# `imsg` CLI Reference
Complete reference for the `imsg` command-line tool installed on Robert's Mac Mini. All commands run via `ssh macmini 'imsg ...'` from any other machine. Internally, `imsg` reads/writes the SQLite database backing Apple's Messages.app and uses AppleScript to trigger sends.
## Table of Contents
- [Connection](#connection)
- [Subcommands](#subcommands)
- [`imsg send`](#imsg-send)
- [`imsg history`](#imsg-history)
- [`imsg chats`](#imsg-chats)
- [`imsg watch`](#imsg-watch)
- [Output Format](#output-format)
- [Error Codes](#error-codes)
- [osascript Fallback](#osascript-fallback)
- [SQLite Direct Access (Last Resort)](#sqlite-direct-access-last-resort)
---
## Connection
```bash
# Aliases (in ~/.ssh/config on Robert's machines)
ssh macmini # robertboulos@Roberts-Mac-mini.local
ssh robertboulos@Roberts-Mac-mini.local # explicit
ssh robertboulos@<tailscale-ip> # via Tailscale (off-LAN)
# Test
ssh macmini 'echo ok && which imsg'
```
If `which imsg` returns nothing, the binary is not on PATH for non-interactive sessions. Try absolute paths in this order: `/usr/local/bin/imsg`, `/opt/homebrew/bin/imsg`, `~/bin/imsg`.
---
## Subcommands
### `imsg send`
Send a new message or reply to an existing conversation.
```
imsg send [--to PHONE | --chat-id ID] --text "MESSAGE" [--file PATH] [--service iMessage|SMS]
```
**Flags:**
| Flag | Required | Description |
|------|----------|-------------|
| `--to PHONE` | one of | E.164 phone (`+14155551212`) or email-as-Apple-ID |
| `--chat-id ID` | one of | Numeric chat ID from `imsg chats` (use for groups & continuity) |
| `--text "MSG"` | yes | Message body. Escape single quotes as `'\''` inside SSH single quotes |
| `--file PATH` | no | Absolute path to file on Mac Mini to attach |
| `--service` | no | Default: `iMessage`. Use `SMS` to force green-bubble (requires SMS Forwarding paired iPhone) |
**Examples:**
```bash
# Simple text by phone
ssh macmini 'imsg send --to +14155551212 --text "Hey!"'
# Reply to existing 1:1 thread (preserves blue/green continuity)
ssh macmini 'imsg send --chat-id 47 --text "Got it"'
# Reply to group chat
ssh macmini 'imsg send --chat-id 92 --text "On it!"'
# With image attachment
ssh macmini 'imsg send --to +14155551212 --text "Screenshot" --file /tmp/screen.png'
# With doc attachment
ssh macmini 'imsg send --to +14155551212 --text "Contract" --file ~/Documents/contract.pdf'
# Force SMS (green bubble) -- requires Continuity SMS Forwarding
ssh macmini 'imsg send --to +14155551212 --text "Test SMS" --service SMS'
# Single quote inside the message
ssh macmini 'imsg send --to +14155551212 --text "It'\''s done"'
# Multi-line message (newlines preserved)
ssh macmini 'imsg send --to +14155551212 --text "Line 1
Line 2
Line 3"'
```
**Exit codes:**
- `0` -- sent (no delivery confirmation)
- `1` -- invalid flags
- `2` -- Messages.app not running / not signed in
- `3` -- file not found (attachment)
- `4` -- invalid phone number format
---
### `imsg history`
Read past messages from a conversation.
```
imsg history [--chat-id ID | --to PHONE] [--limit N] [--start ISO8601] [--end ISO8601] [--json]
```
**Flags:**
| Flag | Description |
|------|-------------|
| `--chat-id ID` | Read by chat ID |
| `--to PHONE` | Read 1:1 conversation by phone number |
| `--limit N` | Max messages to return (default: 20) |
| `--start TS` | ISO8601 start timestamp |
| `--end TS` | ISO8601 end timestamp |
| `--json` | Output JSON instead of human-readable |
**Examples:**
```bash
# Last 10 messages from a chat
ssh macmini 'imsg history --chat-id 59 --limit 10 --json'
# Today's messages from a contact
ssh macmini 'imsg history --to +14155551212 --start 2026-04-07T00:00:00Z --json'
# Date range
ssh macmini 'imsg history --chat-id 47 --start 2026-04-01T00:00:00Z --end 2026-04-07T23:59:59Z --json'
# Pipe to jq for processing
ssh macmini 'imsg history --chat-id 59 --limit 1 --json' | jq -r '.[0].text'
# Extract a 6-digit verification code from the latest SMS
ssh macmini 'imsg history --chat-id 59 --limit 1 --json' | jq -r '.[0].text' | grep -oE '[0-9]{6}'
```
**JSON record shape:**
```json
{
"id": 12345,
"chat_id": 59,
"from": "+16473608487",
"from_me": false,
"text": "Your Slack code is 482919",
"service": "SMS",
"date": "2026-04-07T14:23:11Z",
"attachments": [],
"is_read": true
}
```
---
### `imsg chats`
List all known conversations with their numeric IDs.
```
imsg chats [--json] [--include-archived]
```
```bash
# Human-readable
ssh macmini 'imsg chats'
# JSON
ssh macmini 'imsg chats --json'
# Find a specific group by member
ssh macmini 'imsg chats --json' | jq '.[] | select(.participants | tostring | contains("+14155551212"))'
```
**JSON record shape:**
```json
{
"chat_id": 92,
"display_name": "Snappy Team",
"participants": ["+14155551212", "+16473608487"],
"is_group": true,
"service": "iMessage",
"last_message_at": "2026-04-07T11:42:00Z",
"unread_count": 0
}
```
---
### `imsg watch`
Stream new incoming messages to stdout. Blocks until killed.
```
imsg watch [--chat-id ID] [--json]
```
```bash
# Watch all incoming
ssh macmini 'imsg watch --json'
# Watch one chat
ssh macmini 'imsg watch --chat-id 59 --json'
# Background watch with timeout (for SMS code retrieval)
ssh macmini 'imsg watch --chat-id 59 --json' &
WATCH_PID=$!
sleep 30
kill $WATCH_PID 2>/dev/null
```
Each line of output is one JSON record matching the `imsg history` shape.
---
## Output Format
| Mode | Trigger | Use For |
|------|---------|---------|
| Human | default | Quick eye-checks during interactive SSH |
| JSON | `--json` | All scripted/automated reads -- always use this in skills |
---
## Error Codes
| Code | Meaning | Recovery |
|------|---------|----------|
| 0 | Success | -- |
| 1 | Invalid CLI flags | Check syntax, re-run |
| 2 | Messages.app not signed in / iMessage offline | Robert opens Messages.app on Mac Mini, re-auths Apple ID |
| 3 | Attachment file not found | Verify path on Mac Mini, scp first if needed |
| 4 | Invalid phone format | Convert to E.164 (`+1XXXXXXXXXX`) |
| 5 | Database lock (rare) | Retry after 1-2 sec |
| 6 | Permission denied (Full Disk Access) | macOS System Settings → Privacy → Full Disk Access → enable for Terminal/sshd |
---
## osascript Fallback
When `imsg` is unavailable, drop to raw AppleScript via `osascript`:
```bash
# Send iMessage by phone
ssh macmini 'osascript <<APPLESCRIPT
tell application "Messages"
set targetService to 1st account whose service type = iMessage
set targetBuddy to participant "+14155551212" of targetService
send "Your message here" to targetBuddy
end tell
APPLESCRIPT
'
# Send SMS (requires Continuity SMS Forwarding)
ssh macmini 'osascript <<APPLESCRIPT
tell application "Messages"
set targetService to 1st account whose service type = SMS
set targetBuddy to participant "+14155551212" of targetService
send "Your SMS here" to targetBuddy
end tell
APPLESCRIPT
'
# Send to existing chat by display name
ssh macmini 'osascript <<APPLESCRIPT
tell application "Messages"
set targetChat to chat "Snappy Team"
send "Hi team" to targetChat
end tell
APPLESCRIPT
'
```
**Limitations of osascript fallback:**
- No attachment support without extra AppleScript scaffolding
- No history reading (must query SQLite directly)
- Slower (spawns AppleScript runtime each call)
- Service detection is finicky -- `iMessage` vs `SMS` may need manual specification
---
## SQLite Direct Access (Last Resort)
If both `imsg` and `osascript` are unavailable, the Messages database is at:
```
~/Library/Messages/chat.db
```
```bash
# READ-ONLY query -- never write directly, you'll corrupt Messages.app state
ssh macmini 'sqlite3 -readonly ~/Library/Messages/chat.db "SELECT id, text, date FROM message ORDER BY date DESC LIMIT 5;"'
```
**Warnings:**
- Requires Full Disk Access for whichever process runs `sqlite3`
- Apple's epoch is nanoseconds since 2001-01-01, NOT Unix epoch -- convert: `date / 1e9 + 978307200`
- Schema changes between macOS releases -- don't bake column names into long-lived scripts
- NEVER write to `chat.db` directly. Use `imsg send` or `osascript` to send.