calendar
Framework
Search and list events in Apple Calendar, find the gaps between them,
create one — repeating, with alarms, with invitations — reschedule it, move it between
calendars, delete it, open it, and list the calendars you have.
Reads and writes both go through EventKit now. The writes used to be
Apple Events and the split cost more than it bought: deleting the fourth occurrence of a
five-week series deleted the first, because every occurrence shares one
identifier and the loop removed the one it walked past first; and answering “no such
event” took 60.06s against EventKit's 0.006s. Ids returned for a
repeating event now carry the occurrence after a #, and every write parses
it back.
Needs only the Calendars permission, for reading and for writing. No app is launched —
one Apple Event used to open five Calendar windows. The two exceptions are
open, and the invitees argument on create: EventKit
has no public setter for attendees, so an event that must carry invitations has them
attached over Apple Events after the event itself has been saved.
Signature
calendar(operation, search_text=None, event_id=None, limit=10, from_date=None, to_date=None, title=None, start_date=None, end_date=None, location=None, notes=None, is_all_day=False, calendar_name=None, target_calendar_name=None, invitees=None, span="this", duration_minutes=30, day_start="09:00", day_end="18:00", include_all_day=False, recurrence=None, recurrence_interval=None, recurrence_days=None, recurrence_week=None, recurrence_count=None, recurrence_until=None, alarm_minutes_before=None, alarm_at=None, include_attendees=False, include_details=False) -> str
Operations
- read
- searchlistcalendarsfreecoverageopen
- write
- createreschedulealarmsmovedelete
calendar=read leaves six of the eleven. free returns
gaps rather than events, which is what answers “am I free Thursday
afternoon” and what no list of bookings can. coverage is the orientation
call: calendars lists the names and says which are writable, and nothing said
what is in them, so a read that came back empty meant “nothing there” and “you
asked outside the data” identically. Every figure is counted rather than estimated, over a
window the result states — occurrences per calendar, with series beside them
because a weekly item is 52 occurrences of one thing, and earliest and
latest that are exact inside the scanned window and nothing of the
sort outside it. One extra segment is scanned either side, so a non-zero
before_window or after_window is a fact that there is more.
Calendars holding nothing in the window are listed anyway, which is the answer a tally of
events cannot produce. Measured on a store of 15 calendars: the default window is 7
segments, 4,788 occurrences, 0.378s, and a whole-century scan is 33 segments and 1.08s, so
widening it is affordable. seconds in the result is the real elapsed time of
the call that produced it. reschedule and move
are different verbs because they are different questions — when it happens, against which
calendar it belongs to — and move was declared here, in the server and in the
CLI for a whole release while the handler method did not exist, so every call
raised. alarms is an operation rather than an argument because “remind me
earlier” says nothing about when the event is, and it is the only calendar write that can
neither move nor destroy anything. create is classed write even
though its invitees argument mails an invitation: the class belongs to the
operation, and classing the whole of it send would make “manage my calendar”
cost the same grant as “mail anyone”. Stated rather than hidden —
calendar=write can reach a person, through that one argument, and
create reports invited so the caller can see it happened.
Parameters
Ask for it like this
- What do I have on Thursday afternoon?
- Where am I actually free for an hour this week? Ignore the birthdays.
- Put “Design review” on my work calendar every second Tuesday until the end of term, and remind me fifteen minutes before.
- Push the standup back an hour — just this week, not the whole series.
- Who is actually coming to that?
From a shell
pyapple calendar list · search · calendars · free · coverage · create · reschedule · alarms · move · delete · open
from_date and to_date are --from-date and
--to-date — the canonical spelling, and the one every example here uses;
--since and --until are accepted too, so that the habit
mail and messages teach does not exit 2 here.
The window defaults to now through thirty days out rather than
being left open. pyapple calendar coverage keeps its own default of 20 years
back through 5 years ahead, because it is the one read whose question is what the store
holds rather than what is coming up. pyapple calendar list --calendar-name Work
--json is the whole of a “what is on my work calendar” script — the rows come
back under results, beside a total and a truncated
that say whether --limit cut them — and it exits 3, printing
nothing at all, if Calendars access has not been granted.
A monthly “second Tuesday” rule started on a first Tuesday expands to the start
date plus the two following second Tuesdays. That is iCalendar semantics — the start is
always an occurrence — and it is worth knowing before it reads as a bug.
reminders
Framework
List and search reminders in Apple Reminders, name the lists that exist, create one,
change it, complete it, un-complete it, delete it, open it.
It could not complete a reminder until recently, which is half of what
anybody asks of a reminders app; nor delete one, nor enumerate the lists, so “add these
to my shopping list” could not find the list. And create was broken three
ways at once: it reported failure having created the reminder, it dropped
due_date entirely, and the line meant to apply the date would have set it to
now. Due dates also rendered in UTC, so 20:00 local printed as the following day
and “what is due today” answered about tomorrow.
Everything except open goes through EventKit and needs only the Reminders
permission — a full list comes back in roughly 0.01s, and no window appears. Reminders
used to launch on every write. open is still an Apple Event: it took 15.2s,
and 15.3s to report no match, while its script's only effect was to activate the
app; it is 2.6s now and actually reveals the reminder.
Signature
reminders(operation, search_text=None, name=None, list_name=None, notes=None, due_date=None, show_completed=False, reminder_id=None, priority=None, due_before=None, due_after=None, completed=True, clear_due_date=False, include_completed=False) -> str
Operations
- read
- listsearchlistsopen
- write
- createupdatecompletedelete
reminders=read leaves four of the eight. open stays in
read, and the question was asked properly rather than assumed: it seizes the
screen, which is not nothing, but the classes describe what happens to your data.
mail open and messages open are write because they
mark a message read, not because they raise a window; showing a reminder changes no
reminder. A text query that matches more than one reminder is refused, with the
candidates named — completing the wrong reminder makes it vanish from someone's
list with nothing to explain why, and “first match wins” would make that a coin flip on
iteration order.
Parameters
Ask for it like this
- What's still open on my errands list, and what's overdue?
- Remind me to renew the parking permit on Friday at 9.
- Tick off the two things I finished this morning.
- Which lists do I even have?
From a shell
pyapple reminders list · search · lists · create · update · complete · delete · open
pyapple reminders list --list-name School is one of the examples the
command's own --help prints. search and open take
their text as a positional argument rather than a flag, because there is only one thing
they could be given.
contacts
Framework
Look a person up by name, go the other way from a phone number or an email address, add a
card, add a number or address to a card that already exists, correct the name on one, or
delete one. Called with no name, search returns everyone.
addTo and rename exist because the alternative a caller
reaches for loses data. Asked to “save this number for Alex”, add
made a second Alex; asked to correct a surname, the only route was delete-then-add, which
discards every field this tool never reads. Both refuse an ambiguous match rather than
guessing, because writing to the wrong person's card is not recoverable from anything
this tool knows — the name it would overwrite is the only handle the caller had on it.
One card in nine cannot be written at all. 20 of 179 real cards refuse
every framework write with Cocoa error 134092 — 11.2%, stable per card rather than
intermittent, and nothing the API exposes separates them from the 159 that accept every
write, so it cannot be pre-flighted and retrying is pointless. The refusal says so, and
says that the Contacts app can still edit the card. The failure is
total, never partial: every affected card was byte-identical afterwards.
Reads and writes go through CNContactStore and use the Contacts
privacy row — nothing in this tool dispatches an Apple Event, and a check asserts the
module has no handle on the script runner to reach through. 163 names in 0.128–0.137s
warm against 7.87–8.94s for the corrected AppleScript, and a reverse lookup in
0.005–0.008s that Apple Events cannot perform at all. The script that actually shipped
until 1.3.0 took 8.73s to fail, every time, for every contact with a phone number.
Signature
contacts(operation="search", name=None, first_name=None, middle_name=None, last_name=None, phone=None, email=None) -> str
Operations
- read
- search — defaultlookup
- write
- addaddTorenamedelete
contacts=read leaves two of the six. rename refuses a change
that would change nothing, rather than reporting a success it did not earn — measured,
executeSaveRequest returns true for a save whose values all
equal what is stored, and writes nothing. That fact
invalidated two whole sweeps of the address book before it was noticed.
Parameters
Ask for it like this
- What number do I have for the dentist?
- Who is this number that just texted me?
- Save the plumber from that email as a new contact.
- Add this second number to the card you already have for her.
- His surname is spelled wrong — fix it, don't make a new card.
From a shell
pyapple contacts search · lookup · add · add-to · rename · delete
pyapple contacts lookup --phone "+15550000000" is the reverse direction,
and it is the one worth having in a script: it answers who is this in about
0.008s, and a number nobody knows exits 0 with an empty list rather than
looking like a failure. search with no name lists everyone who has a number.
add-to and addTo are both accepted and reach the same
classification.
add now surfaces the handler's duplicate warning, which the tool used to
discard: a model that has just created a second card with an existing name needs to know,
because the next addTo or delete aimed at that name is refused
as ambiguous and the cause would otherwise be invisible.
maps
Framework
Search places and get directions — distance, travel time, arrival time and turn-by-turn —
and drop a named pin on a map.
Four of these seven operations cannot be implemented, and never will be from
here. Maps.app ships no scripting definition at all, and Favourites and Guides
have no MapKit API and no readable copy on disk — the container's Maps
directory is empty and the data is CloudKit-backed and opaque. So save,
listGuides, createGuide and addToGuide report
failure with unsupported: True rather than returning a cheerful string, and
save and addToGuide hand back a permanent
maps.apple.com link instead — which an assistant can file in a note or a
reminder, and which opens on any Apple device. listGuides must
never answer with an empty list: a store that cannot be read has to stay
distinguishable from a store with nothing in it.
All seven used to report success. Three were AppleScript
syntax errors that had never executed a line for anyone; the other four launched the app
and returned success: True having done nothing, and search
returned the string “please manually search in the Maps app”.
No permission of any kind, and no window. MapKit is a data framework:
verified by quitting Maps, running a search and three route lookups, and confirming the
app was still not running. Search 2.68s → 0.72s, a route lookup 2.23s → 0.12–0.46s. The
remaining latency is a round trip to Apple's geocoder, which makes this the one thing in
the package that reaches the network other than sending.
Signature
maps(operation, query=None, limit=5, name=None, address=None, from_address=None, to_address=None, transport_type="driving", guide_name=None, near=None, radius_meters=5000.0, depart_in_seconds=None, arrive_in_seconds=None, show=False) -> str
Operations
- read
- searchdirectionslistGuides
- write
- savepincreateGuideaddToGuide
maps=read leaves three of the seven, and only two of those three can do
anything. pin is the one write with a route — a documented URL rather than a
script — and it is the only operation here that opens a window. It opens
behind whatever you are working in unless show says otherwise,
because a Maps window cannot be closed programmatically: measured,
close window 1 blocks until the Apple Event times out, roughly two minutes,
and leaves the window exactly where it was.
Parameters
Ask for it like this
- How long is the drive from home to the ferry terminal?
- I need to be at the airport by three — when do I have to leave?
- Find bike shops near the station.
- Put a pin on that address so I can see where it is.
From a shell
pyapple maps search · directions · list-guides · save · pin · create-guide · add-to-guide
The camelCase operations are hyphenated here — list-guides,
create-guide, add-to-guide — and the schema's spellings are
accepted too, so a script ported from a tool call does not have to be rewritten.
directions takes --from and --to, and
--transport chooses between driving, walking and transit.
--from-address and --to-address — the names the schema
publishes and the payload uses — are accepted as aliases, because that is what a
caller reading either of those guesses; --from and --to
stay canonical.
A threading fact that would have made all of this silently broken, and is worth knowing
if you embed the handler yourself: MapKit's search may be started on any thread
but delivers on the main queue, and a synchronous MCP tool runs on a worker. An
in-process implementation therefore hangs to its timeout and then reports “no results” —
the failed read that looks empty, one level below the bug this package exists to avoid.
It runs in a child process whose main thread does nothing but pump, and a check asserts
that a worker thread cannot pump the main runloop.
notes
Apple Events
List, search and view notes in Apple Notes; create one; append a line to one; rename it;
replace text inside it; replace its whole body; delete it. And, one level up, name the
folders that exist, make one, rename one.
The four editing operations are mostly refusals, and that is the feature.
Notes' HTML serialiser and its HTML parser do not agree, so the body it hands you is not a
body it will take back — and since Notes offers no partial edit, every change here is a
whole-body rewrite. Measured: a pure identity write took a note from 2 attachments to 1 to
0; an image went 2 → 0 on the first write; a link keeps its text and loses its URL; five
input forms of a heading all came back as a bold run; ten candidate checklist markups all
came back as a plain bullet list with every tick cleared. So append,
rename, replace and setBody refuse any note
carrying that content. On the development store that is 0 of 56 notes —
40 carry an attachment, 36 an image, 19 a heading, 13 a link, 6 are password protected.
Notes this tool creates always pass.
There is deliberately no override. A flag saying “yes, lose the images”
would be set by a model that has never seen the note, on behalf of a user who has, and
the loss it authorises is invisible afterwards — the attachment is not in the trash, it
is gone. A password-protected note is refused for a related reason: Notes answers its body
and its plaintext as empty strings with no error, so a round trip would blank it.
Driven by Apple Events, so macOS asks once for permission to control Notes. This is the
one tool that cannot move to a framework, because macOS ships none for it. Reads fetch
plaintext rather than body, which is what makes them usable at
all: on a 59-note store the HTML runs to 13,790,681 characters where the text is 14,463,
and it made search wrong as well as slow — body contains "the" matched 37 of
59 notes against 8 for plaintext, the extra 29 matching inside base64 image
data. New notes go where the Notes app itself would file one — the default account's own default
folder — and a folder you do name has to exist already, since nothing here creates one as
a side effect. folders is how you find out which do.
Signature
notes(operation, search_text=None, title=None, body=None, folder_name=None, new_title=None, find=None, replace_with=None, all_occurrences=False, keep_title=True, new_name=None, parent=None, delete_notes=None, limit=50) -> str
Operations
- read
- searchlistviewfolders
- write
- createappendrenamereplacesetBodyfolderCreatefolderRenamefolderDeletedelete
notes=read leaves four of the thirteen. Deleting a note is the archetype of the
write class: it changes this Mac, and the Trash undoes it — though note that
a deleted note is still returned by Notes' own “every note”, so the trash
is excluded by folder name here, best-effort across localisations, and every record
carries the folder it came from. An assistant answering “what did I write down about
that” out of a note you threw away last week is wrong in a way you cannot see. Ambiguity
is refused throughout rather than guessed: appending to or deleting the wrong note is
silent, and you find out later.
The three folder operations are one change rather than three.
create now refuses a folder name it does not recognise
instead of quietly making a folder out of a typo — the tool's own default folder is the
one exception, and it is created if missing because a caller who named no folder should
not be told that the folder it did not ask for does not exist. That refusal removes the
only route this tool had to bringing a folder into existence, so the other two put it
back deliberately: folders is how a caller learns what exists, and
folderCreate means a folder gets created when a caller asks for a folder and
not when a caller misspells one. folders names every folder with its note
count and its place in the tree — account, name,
path, depth, notes, shared,
trash — parents printed before their children, and the trash excluded unless
it is asked for, since a listing is what a caller picks a target from. notes
there is the folder's own count and does not include its subfolders': that is
Notes' own accounting rather than a choice made here, measured as a parent reporting 1
while its child separately held 1. Sibling uniqueness is Notes' rule and Notes enforces
it; a name carrying a line break, or one that is what Notes calls its trash, is refused
on this side. folderRename renames a folder and leaves everything in it
alone, which is worth stating because it is the opposite of the note case
— a note's name is re-derived from its first line, which is the whole reason
rename works the way it does, while a folder's name is a stored property and
the rename is durable. Driven and confirmed against a fresh osascript
process looking the folder up by its unchanged id, and again after a note was created
inside it: the write that reverts a note's name leaves a folder's alone.
Parameters
Ask for it like this
- Find my notes about the roof quote and summarise them.
- Start a note called “Reading list” with the three books we just discussed.
- Add a line to that note with what we just decided.
From a shell
pyapple notes list · search · view · folders · create · append · rename · replace · set-body · folder-create · folder-rename · folder-delete · delete
--limit is on list and on search, and
--limit -1 is every note — it used to return one, because
the stop condition was len(records) >= limit and that is true on the
first record when the limit is -1. A bounded listing now says
showing 5 of 58 notes (--limit -1 for all) rather than 5 notes,
and --json carries the same fact as total and
truncated. view prints one note whole, which is what you want when the thing
reading the output is less rather than a model with a context window.
set-body and setBody are both accepted, and so are
folder-create/folderCreate and
folder-rename/folderRename — the hyphenated spelling is what a
person types and the schema's spelling is what a script ported from a tool call already
says. folders --include-deleted is the only way to see the trash in that
listing, and folder-rename takes the new name as --to.
A search matching nothing exits 0, silently, with an empty result. It used
to exit 0 having printed an error to stderr that did not exist: the branch
asked whether the script's output was truthy, and a search matching nothing
returns the empty string, so every empty search took the failure path.
A failed read now raises like every other tool here, and reaches the CLI's exit
3.
mail
Local database
Read unread mail; search it by sender, subject, thread or arrival time; search what the
messages actually say; read one message whole by its id; ask what each account
can even answer about; send one; reply inside its thread; compose either without
sending; show one message; mark one read; and list your mailboxes and accounts. All
accounts are searched unless you name one.
Two of its narrowing arguments were answering about the wrong mail, and
both are fixed. A Gmail account stores every message once in [Gmail]/All Mail
and records inbox and label membership in a separate table, so the column
mailbox was matching on holds nothing: All Mail carries 30,919 rows and the
two Gmail inboxes carry zero. unread --mailbox INBOX reported 2 unread to
somebody who had 6, and search --mailbox Sent found the 10 Exchange sends and
none of the 1,293 Gmail ones. Meanwhile account was accepted, documented and
never reached the query at all, so naming one account answered about all three.
An unknown account now raises rather than silently widening.
The three operations that act on one message all find it the same way:
reply, open and markRead take a substring of the
subject in search_term, and Mail evaluates the match itself rather than
handing every message across the Apple Event boundary — the mistake that made an earlier
mark_read sweep unusable. Each reports how many messages matched, so a caller
can tell the one I meant from one of several.
Threading is a column in Mail's index rather than a guess from subject lines, and every
result carries the conversation_id it belongs to — pass one back to read the
rest of that thread. Re-deriving threads from subjects instead would, on a
45,628-message index, merge 2,516 subject lines that span more than one conversation
(417 of them under a single subject) and split 354 conversations that change subject
partway.
searchBodies is partial by construction, and “32% of the store” was
the wrong way to say how partial. Bodies are not in the index —
search matches headers because those are the columns — so “find the email
with the booking reference in it” had no answer at all. A body lives in a file on disk
and only downloaded mail has one. The first audit put that at 14,532 files against
45,654 rows and called it 32%. Broken down per account it is not one number and the mean
was hiding the thing that matters:
32% was the mean of a zero and two hundreds, and it reads as partial-but-usable when the
truth is that the largest account answers nothing. That is the same failure shape this
package found six times in its own tools — a figure taken across the thing that
works, hiding a zero in the thing the user has. Hence coverage:
accounts carries it too, so a model that looks before searching is told what
searching can see, and searchBodies says when zero results mean zero bodies
scanned rather than zero matches. It is a separate operation rather than a flag precisely
so the result can carry that; a list cannot say what it did not read.
Reads open Mail's own local index read-only, which is why results return at once and why
this tool needs Full Disk Access. Everything that composes, sends or marks is an Apple
Event, so those also need Automation. markRead alone is given 120 seconds
rather than the usual 30: the work is small, but it happens inside Mail and Mail's
responsiveness is not ours — the same call measured 6.1s idle, 8.7s on a busy machine, and
was cut off entirely at load average 321.
Signature
mail(operation, account=None, mailbox=None, limit=10, search_term=None, to=None, subject=None, body=None, cc=None, bcc=None, full_content=False, search_range=None, mark_read=False, since=None, conversation_id=None, from_account=None, show=False, scan_limit=500, message_id=None, max_chars=20000) -> str
Operations
- read
- unreadsearchsearchBodiesreadMessagecoveragemailboxesaccounts
- write
- openmarkRead
- draft
- draftreplyDraft
- send
- sendreply
mail=read leaves seven of the thirteen; mail=read+draft leaves
nine, which is an assistant that can write your mail and cannot post it.
readMessage returns one message whole by the id every other read already
prints, and marks nothing — reading a message and changing its state are
separate decisions, which is what open is for. 0.152s cold and 3ms warm.
coverage says what each account can actually answer about, which is the
difference between “no matches” and “nothing to match against”.
reply is send rather than draft, because the class
is about where a message ends up and not about how it was composed —
replyDraft is the door that composes an answer without transmitting it, and
it is a separate operation for the same reason draft is separate from
send: the policy grants operations, so a flag would have put writing a reply
and sending one behind one grant. It cuts both ways, which surprises people:
mail=read+send can transmit a reply and cannot file one.
unread is the one honest exception on this list: it accepts
mark_read=True, which does write, so a mail=read grant can still
clear the unread flag on what it reads. Gating that needs a per-argument rule, and the
policy has none by design.
Parameters
Ask for it like this
- Anything unread I should deal with this morning?
- Find the mail from the accountant about the filing deadline.
- Find the one with the booking reference in it — it's in the body, not the subject.
- Open that one and read me the whole thing.
- Can you even search my old account, or is there nothing downloaded?
- Show me the rest of that thread.
- What has come in since nine?
- Reply in that thread and say Saturday works.
- Write the answer to the recruiter but leave it in Drafts — send it from my work address.
- Mark the delivery notification read; I don't need to look at it.
From a shell
pyapple mail unread · search · search-bodies · read · coverage · mailboxes · accounts · mark-read · open · reply · reply-draft · send · draft
Reported and not fixed: every send leaves a copy in the
sender's Drafts. Mail autosaves any outgoing message it can attribute to an account —
measured by building a message that was never saved and never sent, which appeared in
Drafts within 8 seconds and stayed. The copy appears asynchronously, so an in-script
cleanup runs too early and a later one would mean this package deleting from your Drafts
by matching a subject line. The documentation is corrected; the behaviour is not.
mailboxes lists an account that has none. One of the three on the measured
machine is switched off in Mail and answers count of mailboxes with 0, while
its 20,178 messages sit in the index and come back from every read here — so anyone
picking a mailbox off that list was choosing from a menu that omitted a third
of their mail. It gets a line of its own now: not a mailbox name, and it does not pretend
to be one.
This is the tool with the most that is reachable from a shell and not from the schema.
mail reply --all and mail reply-draft --all answer everyone
rather than just the sender,
mail open --select highlights the message in the main window instead of
opening its own, and mail mark-read --unread marks a message
unread — which the markRead operation cannot do at all, since it
calls the handler at its default. mark-read and markRead are
both accepted and reach the same classification.
mail reply-draft composes the reply into Drafts instead of sending it, and
mail reply --draft is the same thing under the spelling that shipped in
1.4.0. Both are the replyDraft operation, so both cost the
draft grant rather than the send one, and
mail=read+draft can answer your mail without being able to post the answer.
The flag is remapped onto the operation name before the gate, because the gate reads the
operation and never the arguments — which is why this was briefly the opposite: while
--draft was a flag on reply, it was invisible to the policy and
a draft grant refused it. That is the same reason mark_read=True still rides
along inside unread above.
messages
Local database
List your conversations, read one by its chat id, search the text of your messages, show
one thread on screen, send an iMessage, or check what is unread.
schedule cannot work, so it refuses. Messages' scripting
dictionary declares three commands and none of them takes a date; the words “schedule”,
“later” and “delay” do not appear in it anywhere. The database has columns that look like
a way in until you notice that all fifteen rows carrying one also carry a send error. It
stays on this surface rather than being removed, because a model asked to send something
at seven in the morning and finding no such operation is likely to reach for
send — and a check pins that it never quietly sends immediately
instead, which is the failure that would actually hurt.
search reads both columns, and the second one is the important
one. Message text is populated on 21,415 of 84,402 rows; the other 62,749 live
entirely in a binary blob. Searching the text column alone found 171 messages for one
term where searching both found 402 — 43% recall, with nothing to say the
rest existed. The blob match is a candidate filter, not the answer: a substring
can land in the blob's own class names rather than in the message, so every candidate is
decoded and confirmed against the text a reader would actually see. 0.07s over 84,402
rows, which is why it is not opt-in.
open is named for what it does, and there is no
markRead here. Messages exposes no read state to AppleScript at all:
a chat has four properties — id, account,
class, name — and both unread count of chat and
set read status of chat to true are syntax errors rather than permission
failures. open displays the thread, which clears its badge as a consequence.
Mail can mark one message directly because Mail's read status is settable;
the asymmetry is the platform's, and it is written down so
that nobody reads it as a bug on this side.
Start with conversations unless you already hold a chat id.
It is how a thread gets found the way a person asks for it: by a participant's name, by a
group chat's name, or by naming two people who are both in the same group —
participants is what narrows one person's six conversations to the four that
contain both. Then hand the chat id it returns to read.
A phone number identifies a person, and a person turns up in many threads, which is why
read prefers the chat id. Before 1.3.0 a 26-participant group read by one of
its members returned 1 message of its 486, and the busiest one-to-one returned 13,078
rows with 1,253 of them belonging to other chats and 16 of its own missing. Scoped
through the chat join, that one-to-one returns 11,841: none foreign, none missing. A
handle that appears in several chats is reported as ambiguous rather than quietly merged.
Reading opens the Messages database read-only, so it needs Full Disk Access. Sending is an
Apple Event. Participant names come from contacts when that is readable, and
a listing that could not reach the address book says so instead of showing nameless
numbers.
Signature
messages(operation, phone_number=None, message=None, limit=10, scheduled_time=None, chat_id=None, query=None, participants=None, since=None, until=None) -> str
Operations
- read
- conversationsreadsearchunread
- write
- open
- send
- sendschedule
messages=read leaves four of the seven. open is write
rather than read because it changes what is on screen and what the badge says
— nothing leaves the machine, and nothing about it is passive either.
schedule sits with send: a schedule is a send with a timer, and
it leaves after the caller has stopped watching. There is no
draft class here at all, and inventing one would be a bug — iMessage
has no unsent message to compose into, so the only thing such an operation could do is
send.
Parameters
Ask for it like this
- Catch me up on the thread with my sister.
- What was decided in the cabin trip group chat?
- What did she say about the address? Search the messages, don't make me read the thread.
- Find the group my brother and my sister-in-law are both in.
- Anything new in that thread since nine this morning?
- Text her that I'll be twenty minutes late.
- Open that conversation so the badge stops nagging me.
From a shell
pyapple messages conversations · read · search · unread · open · send · schedule
The two-step shape survives the trip: pyapple messages conversations --query
"book club" prints chat ids, and pyapple messages read --chat-id 67
--limit 20 reads one of them. --participants takes several names at
once, which is how one group is picked out of the many that share a member.
read takes --since and --until as well, and a page
that was cut prints the bound to hand back to --until for the one before it,
so walking a thread backwards is a loop over what the last page said rather than
arithmetic on an offset.
Both send and schedule are classed send, so
--read-only refuses either with exit status 4 and never
reaches Messages. That refusal is printed on stderr; stdout stays empty, which is what
keeps --json safe to pipe. schedule refuses on its own account
too, at every permission level, because Messages cannot do it.