Tool reference

Seven tools, every argument.

Each tool is one call whose operation argument decides what it does — 65 operations across the seven, from a closed enum rather than free text. They are listed here in the same three groups as the access model: the colour tells you what the tool asks of your Mac. maps sits in the first group and not the second now, because it stopped using Apple Events entirely.

Under each tool the operations are grouped by classread, write, draft, send — because that is what a permission policy grants. Anything outside read is drawn with a dashed border: those are absent from the enum entirely under *=read, which leaves 30 of the 65. The classes are deliberately not coloured; on this site a colour means a route into your Mac and nothing else.

Four operations on this page cannot be performed at all. They are listed because they are on the tool surface, and they are on the tool surface because a model that finds nothing where it expected a verb invents a route to the same place. Each says what is wrong and hands back what can be had instead. They are named under maps.

Every tool on this page is also a subcommand of pyapple, the command-line front end in the same package, and each operation below is the subcommand under it — so the markRead in this reference is pyapple mail mark-read in a shell, and both spellings are accepted there. Each tool carries its shell equivalent underneath. The arguments are the same arguments, and so is the verdict on them: a failed tools/call comes back isError: true on exactly the requests pyapple exits non-zero on, because both front ends grade an outcome through the same classifier. What differs is only the spelling — a shell reads $? where a model reads the sentence in content. An empty result is a success in both: nothing matched is an answer.

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

NameTypeDefaultNotes
operationstrrequiredWhich of the eleven operations above to run.
search_textstrNoneMatched against event titles, locations and notes. Required for search.
event_idstrNoneIdentifies the event. Required for open, delete, move, reschedule and alarms. On a repeating event it carries the occurrence after a #; a bare identifier names the series.
limitint10How many events to return.
from_datestrNoneISO start of the range. Defaults to today. A single named day used to return nothing, because from = to is a zero-width window. On coverage it is the start of the scanned window and defaults to 20 years back instead, because the question there is what is in the store at all rather than what is on soon.
to_datestrNoneISO end of the range. On coverage, the end of the scanned window, defaulting to 5 years ahead. The two are reported back as scanned_from and scanned_to rather than assumed, since every other figure in that result is only exact inside them.
titlestrNoneTitle of the new event. Required for create.
start_datestrNoneISO start. Required for create. A timezone offset is honoured — it used to be discarded, storing 12:00:00+00:00 as noon local, four hours out here and up to thirteen elsewhere.
end_datestrNoneISO end. Required for create.
locationstrNoneWhere the event is.
notesstrNoneFree text stored on the event.
is_all_dayboolFalseMake it an all-day event.
calendar_namestrNoneWhich calendar to create in, or filter by. On coverage it narrows the scan to one calendar; left out, every calendar is reported.
target_calendar_namestrNoneWhere the event moves to. Required for move.
inviteesList[str]NoneEmail addresses to invite. This really does mail people — confirmed against an unreachable .invalid address, which came back as an organiser and a pending attendee that had not been there before.
spanstr"this"For reschedule and delete on a repeating event: this changes the one occurrence, series changes all of them.
duration_minutesint30For free: the smallest gap worth reporting.
day_startstr"09:00"For free: the earliest hour to look inside, so it does not offer you 3am.
day_endstr"18:00"For free: the latest.
include_all_dayboolFalseFor free: count all-day entries as busy. Off, because 211 of the next 365 days on the development machine carry a holiday or a birthday — counting those busy books every day solid. Events marked free or cancelled never count either.
recurrencestrNoneFor create: daily, weekly, monthly or yearly. Explicit fields rather than a sentence, and what it cannot build it refuses by name — a rule that saves cleanly and generates the wrong days is worse than no rule. Cannot be changed on an existing event.
recurrence_intervalintNone“Every 2 weeks” is 2. Defaults to 1.
recurrence_daysList[str]NoneWeekly and monthly only, e.g. ['mon','thu']. A day list on a daily rule is refused.
recurrence_weekintNoneWith recurrence_days on a monthly rule: 1–5, or −1 for last. “The second Tuesday of every month”.
recurrence_countintNoneEnd after this many occurrences. Mutually exclusive with the next one.
recurrence_untilstrNoneEnd on this date, inclusive — a fortnightly rule ending on a named day includes that day.
alarm_minutes_beforeList[int]NoneFor create and alarms, e.g. [15, 60]. Unsigned: 15 means fifteen minutes before. The underlying API takes a negative interval and that sign is the thing callers get backwards, so it is not exposed.
alarm_atList[str]NoneAbsolute alarm times, ISO 8601. Calling alarms with neither argument clears them, which is the only route to “stop reminding me”.
include_attendeesboolFalseFor list and search: names, addresses, participation status, role and organiser. Off by default for two reasons — it roughly triples the cost of a read, and a bare “what have I got on” should not spray a dozen people's addresses into a transcript. Some participants are rooms and equipment rather than people; 41 of 1,010 on the measured machine. Every event always carries a free has_attendees instead.
include_detailsboolFalseFor list and search: read back the recurrence rule and alarms an event actually has.

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

NameTypeDefaultNotes
operationstrrequiredWhich of the eight operations above to run.
search_textstrNoneMatched against a reminder's name and notes. Required for search and open; on update, complete and delete it is how you name the reminder if you do not have its id.
reminder_idstrNoneThe id from a list or search result. Unambiguous, and the way to act on a specific reminder.
namestrNoneName of the reminder to create, or the new name on update.
list_namestrNoneWhich list to read from, file into, or move to. lists tells you what exists; an unknown name is refused, and the refusal names the real ones rather than silently using the default list.
notesstrNoneBody text. Set on create, replaced on update.
due_datestrNonee.g. 2026-08-07 for an all-day reminder or 2026-08-07 09:00 for a timed one. The distinction is kept rather than flattened: an all-day reminder has no time and is not midnight. A value that cannot be parsed stops the write, because a reminder silently due at the wrong time is worse than no reminder — the user believes it is set.
clear_due_dateboolFalseOn update: remove the due date entirely. Distinct from omitting due_date, which leaves it alone.
priorityintNone1–9, where 1 is highest, or 0 for none.
due_beforestrNoneOn list: only reminders due at or before this.
due_afterstrNoneOn list: only reminders due at or after this. With due_before this answers “what is due today” and “what is overdue” without reading everything and filtering afterwards.
completedboolTrueOn complete: pass False to mark a reminder not done — the undo for having completed the wrong thing.
show_completedboolFalseOn list: include finished reminders.
include_completedboolFalseOn delete: allow a completed reminder to match.

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

NameTypeDefaultNotes
operationstr"search"Search unless you say otherwise.
namestrNoneName to look for. Omit it to list everyone. Names the card for addTo, rename and delete, and is required there.
first_namestrNoneFirst name of the new contact, required for add; the new first name on rename.
middle_namestrNoneMiddle name, on rename.
last_namestrNoneLast name, on add and rename. A part left out is left alone, and there is no way to clear a name here: over MCP “not given” and “set to empty” arrive as the same value, and guessing wrong wipes the only name a card had.
phonestrNoneNumber to look a person up by, for lookup. Number to store, for add and addTo.
emailstrNoneAddress to look a person up by, for lookup. Address to store, for add and addTo. lookup needs one of these two; addTo needs one of them as well.

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

NameTypeDefaultNotes
operationstrrequiredWhich of the seven operations above to run. Four of them refuse — see above.
querystrNoneWhat to look for. Required for search.
limitint5How many results to return.
nearstrNoneAn address to search around. Without it MapKit picks its own region, so “the nearest pharmacy” is only as near as it guesses. Pass this whenever “near me” is what was meant.
radius_metersfloat5000.0How wide to search around near.
namestrNoneName of the place. Required for pin.
addressstrNoneStreet address. Required for pin. The address is resolved first, so an address nobody can find fails before a window nobody can close appears.
from_addressstrNoneWhere you start. Required for directions, and always required: there is no current location available, because a process that is not a bundled app cannot hold the Location Services grant. “From here” is something the caller has to supply.
to_addressstrNoneWhere you are going. Required for directions. Each end is geocoded separately, and one that cannot be found is reported by name.
transport_typestr"driving"driving, walking or transit, and it is honoured — the previous version accepted the argument and never referenced it again, so “walking” and “teleport” produced identical output. Anything unrecognised is refused rather than quietly treated as driving. Cycling is not offered, because the API has no cycling type. Transit returns an ETA but no step-by-step route, which Apple does not expose outside the app.
depart_in_secondsfloatNoneLeave this many seconds from now, for a traffic-aware “what if I go after lunch”.
arrive_in_secondsfloatNoneArrive by this many seconds from now — which is what answers “I need to be there by three, when do I leave?”
showboolFalseFor pin: bring Maps to the front. Off, so the window opens in the background and nothing is seized. See the note above about not being able to close it again.
guide_namestrNoneWhich guide. Accepted by createGuide and addToGuide, both of which refuse.

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

NameTypeDefaultNotes
operationstrrequiredWhich of the thirteen operations above to run.
search_textstrNoneText to match, or a note's title. Required for search, view, delete, and for naming the note that append, rename, replace and setBody act on.
titlestrNoneTitle of the new note. Required for create.
bodystrNoneBody of the new note, for create; the text to append, for append; the whole new body, for setBody.
folder_namestr"Claude"Folder the new note is filed in, and the folder itself on folderCreate and folderRename — the one being made, and the one being renamed. There is no second parameter for it. A name or a full path, and a bare name that two folders share is refused as ambiguous rather than guessed, since Notes permits the same name at two levels. The default folder is created in the default account, not account 1 — on the measured machine those are an Exchange account holding 3 notes and an iCloud account holding 56, and every note this tool created would have gone to the wrong one had it compiled at all.
new_titlestrNoneThe new title, for rename. A note's name is its first lineset name does not survive the next body write — so renaming rewrites that line in place and keeps its styling.
findstrNoneLiteral text to find, case-sensitive. Required for replace. Text spanning a line break is refused outright rather than left to guess.
replace_withstrNoneWhat to put there. Empty deletes the found text.
all_occurrencesboolFalseReplace every occurrence rather than the first.
keep_titleboolTrueFor setBody: keep the note's existing name. False renames it from the new first line, which is what Notes would do on its own.
new_namestrNoneThe folder's new name, for folderRename, where it is required alongside folder_name. One line. Distinct from new_title, which renames a note and has to rewrite its first line to do it; this is a stored property and setting it is enough.
parentstrNoneFor folderCreate: the name or path of a folder to nest the new one inside. Left out, the folder is created at the top level of the default account. It resolves the same way folder_name does, so an ambiguous name is refused before anything is created.
delete_notesintNoneFor folderDelete: the exact number of notes you expect to destroy, re-checked against the live store. Omitted, a folder holding any note of its own is refused. The interlock exists because the notes are destroyed, not trashed — measured, the trash held 3 notes before and 3 after, and every note matched 0 of the two deleted bodies. A folder with subfolders is refused with no override at all; so are the trash and any account's default folder. The result carries may_return, because a synced account may put the folder back — measured on both account types on one machine, an Exchange folder inside 15 seconds and iCloud five times, and reliably on neither. On an empty folder the message says only that; the note about Recently Deleted appears only when notes were actually destroyed.
limitint50Notes returned by search and list; -1 (or 0) for all of them. It is published because those two operations now carry a truncation notice that says pass limit=-1 for all of them, and until this parameter existed there was nothing to pass. It bounds the answer and not the work: the Notes script is unbounded whatever it says, so every matching note's full text crosses the wire either way.

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:

AccountIndexedBodiesNotes
dormant IMAP31,1430%Spans 45 months and ends 14 months ago. Answers no body search at all.
primary IMAP14,245100%Spans 39 months, to today.
Exchange279100%Spans 4 months, to today — because Mail is set to keep four months on that account. No code here widens that, and Mail exposes no search verb to AppleScript, so launching it buys nothing.

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

NameTypeDefaultNotes
operationstrrequiredWhich of the thirteen operations above to run.
accountstrNoneRestrict to one account. All accounts are searched by default. An account this Mac does not have raises, rather than widening back to everything. It used to resolve through tell application "Mail", which starts Mail — a local SQLite read opening your mail client. The uuid now comes from the mailbox URL and the address from the sender of that account's own Sent mail, matching Mail's own answer 3 of 3.
mailboxstrNoneRestrict to one mailbox. The predicate follows Gmail's label table as well as the mailbox column, and the result says which label matched. Asking for Trash returns the trash, which it used to exclude and then report success over.
limitint10How many messages to return.
search_termstrNoneFor search: matched against headers — sender, subject, recipients and attachment names — because those are columns and the match is a predicate over every row. Recipient matching is the only way to ask “did my message to them go out”, since on your own mail the sender is always you. Required there unless since or conversation_id narrows it instead. For searchBodies it is matched against the text of the message. For reply, open and markRead it is a substring of the subject of the one message you mean, and it is required.
scan_limitint500For searchBodies: how many messages, newest first, the budget reaches. Decoding the 500 newest bodies is 2.25s and 2,000 is 5.98s, so it is a real budget and the default is the one that answers in about two seconds.
message_idintNoneFor readMessage: the id every other read already prints in its results. Required there.
max_charsint20000For readMessage: where the body is cut. The result carries body_truncated so a caller can tell a short message from a clipped one — never a bare truncated, which belongs to the envelope and means rows were left behind.
tostrNoneRecipient. Required for send and draft.
subjectstrNoneSubject line. Required for send and draft. A reply takes Mail's own subject; it does not need this.
bodystrNoneMessage body. Required for send, draft and reply. On a reply, Mail's quoted original follows it.
ccstrNoneCarbon copy address. Carried by send, draft and reply.
bccstrNoneBlind carbon copy address. Carried by send, draft and reply.
full_contentboolFalseReturn whole messages instead of truncating them. Worth knowing what you were paying for before: HTML bodies came back 55% layout, because the contents of a <style> block survived tag-stripping as prose — two measured bodies were 84% and 86% whitespace, and one carried about 2,300 characters of text inside 15,980, so against a 10,000-character cap you bought indentation and lost the end of the message.
search_rangeintNoneHow many recent messages to scan per inbox. Ignored when the local index is used.
mark_readboolFalseMark what it retrieves as read. Applies to unread.
sincestrNoneISO 8601 — return only mail received at or after it, e.g. 2026-08-05 or 2026-08-05T09:00:00. A predicate in the query, not a filter afterwards, so a limit of ten means ten of the new ones. A value that cannot be parsed is an error, never a silent full result.
conversation_idintNoneRestrict results to one thread. Every result carries the id of the conversation it belongs to; pass one back to read the rest of it.
from_accountstrNoneWhich account to send or draft from, e.g. you@example.com. Without it Mail uses its default account, which on a machine with several is a choice the caller cannot see and the recipient reads first.
showboolFalseFor draft. Open the compose window instead of filing the draft quietly. Left False, the draft lands in the sending account's Drafts and nothing appears on screen. A compose window cannot be closed again from AppleScript, so passing True commits the person at the keyboard to dismissing it themselves.

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

NameTypeDefaultNotes
operationstrrequiredWhich of the seven operations above to run.
phone_numberstrNoneWho to send to. Required for send unless chat_id names a chat, and it is chat_id that reaches a group — sending to one participant's number starts a separate one-to-one, and nothing in the result would say the group never saw it. Accepted by read and open, but prefer chat_id in both.
messagestrNoneWhat to say. Required for send.
limitint10How many messages, or how many conversations, to return.
scheduled_timestrNoneAccepted by schedule, and ignored, because schedule refuses. Nothing is sent.
chat_idintNoneThe conversation to read, open or send to, as returned by conversations — and printed in front of every row of unread, so the identifier that gets you to the rest is already on screen. The only way to read or reach a group thread, and the only way to be certain you are reading one conversation rather than one person's half of several. Also narrows search to a single thread.
querystrNoneFor conversations: matches a group chat's name, a participant's name, or a raw number or address. Ranked. For search: the text to look for, case-insensitive, matched as a substring.
participantsList[str]NoneFor conversations: names or numbers that must all be in the chat. This is how you pick one group out of the several that share a member.
sincestrNoneISO 8601 — only messages at or after it, and it is the inclusive half of a window whose other half is until. A predicate in the query, not a filter afterwards. A value that cannot be parsed is an error, never a silent unfiltered result.
untilstrNoneISO 8601, exclusive. With since it is a half-open window — since <= date < until — which is what makes “what did we say in March” one call rather than a walk. It is also the handle for paging backwards through a long thread: read reports how much of the window it showed and, when it showed less than all of it, names the bound to pass back here for the page before. A timestamp and deliberately not an offset. An offset is counted from the end of a live conversation, so one message arriving mid-walk shifts every later page and the caller both repeats and skips with no way to notice; a bound is fixed. What that trades is one duplicate at the boundary rather than a silent gap — driven against the real store, two pages of 20 on each of the three busiest threads overlapped by exactly the one boundary message each time. This is a way to ask a narrow question narrowly and not a way to fetch a whole conversation: the busiest thread on the measured store holds 11,608 messages, which is why the answer to “I need more” is another page rather than a larger limit. Applies to read. Unparseable raises, exactly as since does.

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.