| pingA | Health check — confirms macos-apps-mcp is alive. No permission needed. |
| doctorA | Diagnose per-surface macOS permissions + health with exact remediation. Read-only and prompt-free by default. request=True also triggers permission
prompts (EventKit consent + per-app Automation probes) — use it once to grant. |
| nowA | Current local date, time, timezone, UTC offset, weekday. No permission needed. Call this FIRST to ground any relative date ("today", "tomorrow", "next Friday") —
never guess the date from memory. Every date parameter on the write tools is
interpreted in THIS timezone (naive ISO = local time). |
| auditA | Recent write audit entries (newest first) — what macos-apps-mcp changed, with
before/after pointers, enough to reverse a change by hand. since optional ISO
datetime (call now() to ground it) drops older entries; bounded to the last 50.
Each entry carries dry_run (resolved from the tool's default when the caller
omitted it, so a preview reads true) and caller (the MCP client's name and
version, plus session for a shim); caller is reported by the client, never an
identity check. Read-only; no permission (reads a local log at
~/.local/state/macos-apps-mcp). |
| usageA | Per-tool call frequency, for pruning rarely/never-used tools. Returns tools
(each {tool, count, first, last}, busiest first), never_used (registered tools
with zero calls — the pruning list), and total_calls. Read-only; no permission
(reads a local log at ~/.local/state/macos-apps-mcp). |
| remindersA | List reminders as pointers in {results, coverage?}. due: today | overdue |
this-week | a list name. Each result may carry folder (its list's id, from
reminder_lists; pass it as list_name=), tags and parent (the parent
reminder's id, for a subtask). Requires EventKit and Full Disk Access (tags and
parent links come from the Reminders store). Tags and subtasks are read-only:
macOS has no public API that writes them, so no tool here can add a tag or nest a
reminder — do that in Reminders.app. If the Reminders store cannot be read, results
still come back and coverage says why tags and parent links are missing.
Read-only. Hydrate none — pointers only. |
| eventsA | List calendar events as pointers. when: today | week | YYYY-MM-DD.
Each result's folder is its calendar's id (from calendars); pass it to
free_busy(calendars=…) or as calendar=. A read cannot tell that a series'
first occurrence is extra (each result holds its own occurrence start); the
create/update result says so. Read-only; needs EventKit (Calendar) access. |
| free_busyA | Availability in a window: merged busy intervals + free gaps. start/end are
ISO-8601 datetimes (naive local, e.g. 2026-07-20T09:00:00); calendars optional
Pointer ids (from calendars) to restrict to, else all. Returns {"busy": [...],
"free": [...]} of {start, end} — no event details. Read-only; needs EventKit. |
| reminder_listsA | List reminder lists as pointers (id + name); use a name to target writes. The
id is the folder that reminders carry. Read-only; needs EventKit (Reminders)
access. See create_reminder to write. |
| calendarsA | List calendars as pointers (id + name); use a name to target writes. The id
is the folder that events carry. Read-only; needs EventKit (Calendar) access.
See create_event to write. |
| contactsA | Find contacts by name (substring). Returns pointers (id + name/org).
Read-only; needs Automation access for Contacts. See create_contact to write. |
| mailA | Search the Mail inbox by subject OR sender substring. Pointers: id = the stable
RFC822 message-id, summary = subject — sender, deeplink = a message:// URL,
folder = "inbox" (this scans the inbox only) — pass that folder straight to
mail_body. Prefer mail_search, which reaches every mailbox and reports the
account. Returns {results, truncated?} — truncated means the 25-message cap was
reached and there may be more. Read-only; needs Automation access for Mail. Bodies
are never fetched. |
| mail_bodyA | Full plaintext body of one message by id (bounded + truncation-marked). id is a message-id from mail/mail_search — or from a note written months ago:
an id resolves ON ITS OWN, so a stored citation still works after the message was
filed or moved. mailbox is OPTIONAL and is the disambiguator: pass the folder
value from the SAME search result back VERBATIM — it is an opaque round-trip token
(imap://<uuid>/<path>), not a folder name to retype — or one of the five canonical
names ("inbox"/"sent"/"drafts"/"trash"/"junk"). With it the read is Automation-only
and an id absent from that mailbox raises; without it the id is resolved through
Mail's index (Full Disk Access) to the same copy every other read cites. Read-only.
|
| mail_bodiesA | Plaintext bodies for up to 20 message ids in ONE call — the bulk read behind
"catch me up on this thread". Opt-in and bounded: reading a thread stays
mail_search -> mail_thread -> mail_bodies, three calls total, and with
mail_thread(snippets=True) often only two. Returns {"results": [{"id", "body"}], "missing": [ids]}. An id whose message can't
be read off disk is listed in missing — it is NEVER returned with an empty body,
so "unreadable" is never mistaken for "the sender wrote nothing". Each body is
truncated at the usual per-body budget with an explicit marker; if the batch total
is too large the call REFUSES (output_overflow) rather than dumping — ask for fewer
ids. Reads Mail's message files at rest: fast, no Mail launch, and it sees messages that
arrived seconds ago (unlike the mail_search(body=) FTS index, which only knows what
mail_index_bodies last swept). Read-only. Needs Full Disk Access. |
| mail_attachmentsA | List attachments on messages in a Mail mailbox, or on ONE message (Automation). mailbox: the folder value from a mail_search result passed back VERBATIM (an
opaque imap://<uuid>/<path> token, not a name to retype), or a canonical system
mailbox — "inbox" | "sent" | "drafts" | "trash" | "junk" (resolved via Mail's
unified, cross-account accessors). query: optional
subject substring — an empty/omitted query lists ALL messages in the mailbox
(bounded), unlike mail/get_pointers which rejects an empty query. Use this to
confirm an attachment landed on a DRAFT before it's saved (a freshly opened compose
window has no stable id yet — once saved to Drafts it does, see drafts()).
message_id: address ONE message instead of scanning; with no mailbox the id
resolves on its own (Full Disk Access), and query is then ignored. Prefer it when
you know the message — a mailbox scan stops at 25 records, so the one you meant is
often past the cap. Either mailbox or message_id is required.
Returns {results, truncated?} over
[{id, deeplink, folder, summary, attachments: [{name, size, downloaded}]}].
id/deeplink identify the carrying message and folder names the mailbox read,
so a row is actionable on its own. An unsaved draft has no message-id yet:
its id is "" and it carries no deeplink — it is still listed. |
| mail_needs_responseA | Inbox messages that likely need your response, ranked with a machine-readable
reason (flagged / unread-direct / unanswered-direct). Heuristic over headers +
message properties — no body is read; keeps direct-addressed, not-yet-replied mail.
Each pointer carries folder = "inbox", so an id from here goes straight into
mail_body/mail_reply with no second lookup. No account: this reads Mail's
unified inbox across every account, so the owning account is genuinely unknown —
use mail_search when you need it. Returns {results, truncated?}; truncated
means the 25 cap was reached. Read-only; needs Automation access for Mail. |
| mail_awaiting_replyA | Messages YOU sent more than days ago (1–365, default 3) with no reply, ranked
oldest-first, reason awaiting-reply. Uses real In-Reply-To/References threading. A
group send is cleared once any recipient replies. Each pointer carries
folder = "sent" — ready for mail_body/reply_all; no account (the scan spans
every account, see mail_needs_response). Returns {results, truncated?}; truncated
means the 25 cap was reached. Read-only. Fast path reads Mail's index at rest
(needs Full Disk Access; never launches Mail); without it, degrades to an
AppleScript scan — Automation access is the floor, and that path is SLOW on a
large store. |
| mail_searchA | Indexed search across ALL mailboxes via Mail's Envelope Index — fast and
read-only. All filters optional and ANDed:
subject/from_/to substrings, mailbox a mailbox NAME exactly as mail_overview
reports it ("Junk E-mail" — case-insensitive substring; the encoded spelling also
works; a name that matches nothing RAISES — it is a typo, not an empty mailbox —
while a stale folder url just returns no results), since/until (epoch seconds on
received date),
unread/flagged. body searches message TEXT via the FTS index and is BEST-EFFORT —
it only sees messages already indexed by mail_index_bodies (run that first;
incomplete coverage is normal). At least one filter required. Returns citable
Pointers, newest first. Falls back to AppleScript inbox search on missing Automation
access / schema drift.
has_attachments means a real DOCUMENT — inline signature/newsletter images are
excluded, so it will not match a mail whose only attachment is a logo. account
takes a display name ("Personal") or a raw account UUID; the UUID form stays pure
sqlite, while a NAME has to be resolved through Mail and therefore LAUNCHES Mail if
it isn't running (an unknown name raises rather than guessing). Every other filter
reads the index at rest and never launches Mail.
Read-only; needs Full Disk Access, plus Automation access for Mail on the
account-name path and the AppleScript fallback.
Each Pointer carries folder (the round-trip mailbox token) and account (the
owning account's uuid — map it to a name with one mail_overview call), so a hit
is complete on its own: no second read to work out where it lives.
Returns {results, truncated?, plane?, coverage?}. truncated means the answer came
back AT limit (25 max) and there may be more — do NOT report such a search as
exhaustive. plane = "applescript-inbox" means the sqlite index was unreachable and
this scanned the INBOX ONLY. coverage explains an empty body= answer: bodies are
only matchable once mail_index_bodies has indexed them. |
| mail_threadA | Every message in the conversation containing id, oldest-first — the transcript,
including messages YOU sent. Deduped: a message filed in several mailboxes appears
once. Returns {results, truncated?} of citable Pointers. Over limit messages the
OLDEST are dropped (and truncated says so), since a thread is usually read to
reply to it. Unknown id returns no results. snippets=True adds a short snippet (first ~200 chars of the body) to each
pointer, read off disk in one pass — no Mail launch and no extra calls. Use it to
see WHO SPOKE LAST and roughly about what before deciding which messages are worth
hydrating; then mail_bodies(ids) for the few that are. A message whose body can't
be read carries no snippet key at all (absent, not empty). Leave it off for a
long thread you only need to enumerate.
Fast, read-only, no Mail launch. Needs Full Disk Access. |
| mail_overviewA | Every mailbox with its message total and unread count, unread-first — the triage
entry point ("what's unread where?"). Rows are {account, account_id, mailbox,
folder, total, unread}: account_id is the same uuid every mail Pointer reports as
account, so ONE call here is the whole uuid -> name map; folder is the exact
mailbox url, so a mailbox picked here round-trips into mail_search/mail_body
without going back through name matching. Includes Junk/Trash/All Mail so you can
see what they are rather than having them silently filtered. Counts are computed
live, not read from Mail's own stored counters, which go stale, and each message is
counted ONCE even when it is filed in the same mailbox twice.
Account NAMES are looked up through Mail, so this tool does contact Mail (launching
it if it isn't running); when Mail is unreachable the account UUID stands in and
the counts — which never needed Mail — are still correct. An account Mail answers
but does not list (for example a removed one) shows "(account not in Mail)" as its
account; account_id keeps the uuid. The On My Mac store is
always shown as "On My Mac": Mail never lists it as an account.
Fast and read-only. Needs Full Disk Access for the counts and Automation access for
Mail for the account names. |
| mail_index_bodiesA | Build/refresh the opt-in FTS body index used by mail_search(body=…). Reads every
.emlx file at rest, .partial ones included (never launches Mail, never writes in
Mail's data). Resumable and size-capped — safe to re-run; a re-run continues where
it left off. rebuild=True re-indexes from scratch. Returns {indexed, skipped,
total_emlx, capped, indexed_this_run}. Read-only for Mail; the one thing it writes
is this server's own FTS sidecar. Needs Full Disk Access (it reads .emlx at rest),
NOT Automation. indexed_this_run is THIS RUN's progress, NOT coverage: a resumable indexer's own
counters cannot say how much of the store is searchable (a fully-indexed store
re-runs as "14 newly indexed"). mail_search(body=…) is the one place that answers
"is body search usable?", and it does so on the path that needs it — an empty
result.
A .partial.emlx is missing its ATTACHMENTS, not its body, so partials are indexed
like any other message (#119). The residual unsearchable tail is ~0.5%: messages
Mail never stored a text part for. |
| mail_index_idsA | Build/refresh the Message-ID sidecar that enables the sqlite mail plane on
macOS 15 (Sequoia) and earlier (#201). Those systems' Envelope Index never stored
the RFC822 Message-ID (#199); this harvests it headers-only from the .emlx files
at rest into this server's own sidecar — never launches Mail, never writes in
Mail's data. On macOS 26+ the index has the column natively and this build is
unnecessary. Safe to re-run: a re-run harvests only what is missing and retries
messages whose files had not been downloaded yet; rebuild=True starts from
scratch. A full first build reads every message's headers — about a minute per
35k messages on a slow disk. Returns {harvested, mapped, total_ids, coverage,
skipped_no_file, skipped_no_message_id, rows_without_local_store,
high_water_rowid, index_rebuilt}. coverage is the number that matters:
messages it could not map stay uncitable, exactly as they would be on macOS 26
until Mail backfills its own column. Read-only for Mail; needs Full Disk Access
(it reads .emlx at rest), NOT Automation. |
| mail_statsA | Mail volume, read ratio and top senders over the last days (Full Disk Access). Pure Envelope Index — never launches Mail. Counted per DISTINCT message, not per
row: the same message filed twice is one message (raw rows overcount by up to 3.6x
on a real store). account takes a raw account UUID — the account field every
mail Pointer and mail_overview row carries — not a display name. Returns a compact aggregate: {window_days, since, messages, unread, read_ratio,
flagged, with_attachments, per_day, busiest_day, top_senders[10],
top_mailboxes[10], plane}. Deliberately token-bounded: top-N only, no per-day
series. top_mailboxes carries the decoded name plus folder (the round-trip url)
and the account uuid — map the uuid to a name with one mail_overview call.
Read-only. |
| export_mailA | Write messages out as importable .eml files (Full Disk Access). Read AT REST — the same on-disk bytes the undo backups copy — so this never
launches Mail and needs no Automation. ids are RFC822 message-ids from a mail
read, comma-separated, max 25. dest_dir must be inside the allowed root
(~/Downloads by default; MACOS_APPS_FILE_ROOT moves it) and is created if needed;
a path outside it is REFUSED. Filenames are derived, never overwritten. A name that
already exists refuses the whole batch before any file is written; a refusal after
a write names the files already written. .eml is the only format — it is lossless and opens in Mail and everything else,
and mail_body already covers "give me the text". Returns {results, written, dest_dir, plane}. A message with no local file is
reported per-id as status: "absent" rather than failing the batch.
fidelity: "partial" means the .eml is missing its ATTACHMENT payloads — the body
is complete (#119). It is lossy for archival, not for reading.
Additive: writes new files, never overwrites or modifies mail. |
| save_mail_attachmentA | Save ONE attachment to disk (Automation, plus Full Disk Access to resolve a bare
message_id). List them with mail_attachments first, then address one by name or — when
several on the same message share a name, which is ordinary — by its
attachment_id (the id field on the attachment row). An ambiguous name RAISES
and lists the ids rather than picking one. dest_dir must be inside the allowed root (~/Downloads by default;
MACOS_APPS_FILE_ROOT moves it) and is created if needed; a path outside it is
REFUSED. The filename is DERIVED from the attachment's name — an attachment name
comes from whoever sent the mail, so it is treated as hostile — and an existing
file is never overwritten. Writes are capped at 25 MiB.
mailbox is optional: the folder value from the read that produced message_id,
verbatim. Omitted, the id resolves on its own through the index.
Saving an attachment Mail has not downloaded makes Mail FETCH the message first, so
this can take much longer than a read; if the account is offline the file lands
empty, and it is then removed and reported as a failure rather than left behind.
Returns {saved, name, original_name, bytes, reported_size, was_downloaded, id,
folder}. Additive: writes a new file, never modifies mail. |
| create_draftA | Create a Mail draft and OPEN it for you to review and send — it NEVER sends on
its own. to a recipient address. Returns a locator dict ({"created", "subject",
"mailbox", "from", "note"}) — a freshly opened compose window has no stable id
yet, so this says where to find it (Drafts) instead of fabricating one; save it
and drafts() resolves it by its stable message-id. If the create FAILS partway,
Mail may still leave a stray autosaved draft behind (#133 — its autosave is
asynchronous and cannot be suppressed); drafts() + delete_draft() clear it. A new draft is unthreaded; it is sent from from_address (an address one of
Mail's accounts owns, matched case-insensitively, Name <addr> accepted) or
Mail's default account.
An address no account owns is refused before Mail builds anything — Mail would
otherwise use its default account silently. To answer a message use
mail_reply, which is threaded and sent from the account that received it. THE AUTOSAVE WINDOW: Mail stamps the Message-ID only when it autosaves the draft,
~10-15 SECONDS after this returns — asynchronously, and nothing can hurry it. So
drafts() called immediately shows nothing. That is normal. Wait for the window,
then drafts() resolves it. Do NOT retry this call because the draft has not
appeared: you get two drafts and the first one still arrives.
Additive (creates a draft; does not send/modify/delete); needs Automation access
for Mail (the ownership read is Automation too). |
| mail_replyA | Reply to a message, opening a threaded draft for review (Automation). NEVER sends. message_id: the RFC822 id from a mail read. mailbox is REQUIRED: the
folder value from the SAME search result, passed back VERBATIM (an opaque
imap://<uuid>/<path> token; the five canonical names also work). Mail sets the
threading headers natively; include_quote appends the quoted original. Returns a
locator dict — save the draft to Drafts and drafts() resolves it by a stable
message-id. THE AUTOSAVE WINDOW: that message-id is stamped only when Mail autosaves the draft,
~10-15 SECONDS after this returns — asynchronously, and nothing can hurry it. So
drafts() called immediately shows nothing; that is normal, not a failed create.
Wait for the window, then drafts() resolves it. Do NOT retry this call
because the draft has not appeared: you get two drafts and the first still arrives.
Note a reply draft cannot be sent with send_mail(draft_id=…) — rebuilding it
would drop the threading headers, so that call refuses and points you at
reply_all(dry_run=False) instead. |
| draftsA | List Mail drafts, newest mailbox order. Returns {results, truncated?};
truncated means the 25 cap was reached. Each record is a citable pointer (id,
summary, deeplink, folder = "drafts") PLUS discrete subject and to fields, so
picking out one specific draft is a field comparison, not substring-matching the
summary — which collides whenever two drafts share a subject. The id is the RFC822
message-id: pass it to delete_draft, or to send_mail(draft_id=…) to send a
draft the human has approved.
A compose window Mail has not autosaved yet (~10-15s) is absent here — wait, do not
retry the create. Read-only; needs Automation access for Mail. |
| delete_draftA | Delete one Mail draft by its message-id (from drafts()). dry_run DEFAULTS TO
TRUE — previews the draft that WOULD be deleted (pointer, no mutation); pass
dry_run=false to delete. Destructive but LOCAL — this deletes an unsent draft, it
never sends. Needs Automation access for Mail. |
| create_mailboxA | Create a Mail mailbox (folder) under one account. name may contain "/" to nest
("Projects/2026") — missing parents are created for you. account is a display name
("Personal"), an account UUID, or "On My Mac", and is REQUIRED: a folder has to land
somewhere specific. Returns {created, mailbox, account, folder, note}. folder is the address token to
pass back verbatim to move_mail/mail_search; it works immediately, and
mail_overview will report Mail's own equivalent spelling of it once the account
syncs — both address the same mailbox. A "%" in the name is rejected (it cannot be
addressed unambiguously). There is no delete counterpart: removing a mailbox is not
scriptable, so do it in Mail. In a Gmail account the new mailbox is a label:
move_mail refuses it at either end (#291), and reads show it empty while Mail's
index does not mark it as a label. Additive (creates a folder; touches no message);
needs Automation access for Mail, plus Full Disk Access to resolve "On My Mac". |
| move_mailA | Move messages between Mail mailboxes — archive, file, or refile. DESTRUCTIVE but
recoverable: every message's bytes are copied to a backup directory and its source
mailbox is logged BEFORE anything moves, so mail_undo puts the batch back. dry_run DEFAULTS TO TRUE — the preview reads Mail and reports, per id, whether it
is actually present in from_mailbox. Pass dry_run=False to move.
ids are RFC822 message-ids from a mail read, comma-separated; max 25 per call and
the cap is not overridable. BOTH mailboxes are required and are address tokens.
from_mailbox must be the folder value from the read that produced the ids,
passed back VERBATIM (an opaque imap://<uuid>/<path> token, not a name to
retype); a canonical name there is refused. to_mailbox may also be one of the
canonical "inbox"/"sent"/"drafts"/"trash"/"junk", which files into the SOURCE
message's own account (it names Mail's cross-account accessor, not one mailbox),
so it is refused for an On My Mac source — that store has none of the five. A
Gmail label folder is refused as either end (a move out of a label left a copy
on device, so mail_undo cannot reverse a move into one), and a canonical
to_mailbox is refused when the source account has Gmail label folders. Pass a
folder url from mail_overview when in doubt. To archive, move into a mailbox
named Archive — there is no separate archive tool.
Cross-account moves are supported and leave exactly ONE copy (out of a Gmail label
they do not, which is why a label is refused). Verification is
by-ID (#206): each copy's internal reference must go dead after the move, and the
destination's count of that Message-ID must rise — presence alone can't tell a
landed copy from a pre-existing one. A Message-ID with several copies in the
source gets every one of them moved; ok means every copy left. A per-id
status reports what really happened rather than assuming success. If the call
times out mid-batch, the error names the receipt — re-run the same batch, or
mail_undo it.
Returns {op, receipt, count, succeeded, targets, destination, backup_dir, undo} —
keep receipt to undo the batch. Needs Automation access for Mail,
plus Full Disk Access to locate each message's file for the backup.
|
| trash_mailA | Move Mail messages to Trash — soft delete, and the ONLY delete there is. Mail's scripting layer cannot permanently erase a message: delete moves it to the
owning account's Trash, and nothing in the dictionary empties Trash from a script.
Emptying Trash is something the user does in Mail.app. So this is always
recoverable, and there is no permanent-delete tool to ask for. DESTRUCTIVE but recoverable: every message's bytes are copied to a backup directory
and its source mailbox logged BEFORE anything is deleted, so mail_undo moves the
batch back out of Trash. dry_run DEFAULTS TO TRUE — the preview reports, per id,
whether it is actually present in mailbox. Pass dry_run=False to delete.
ids are RFC822 message-ids from a mail read, comma-separated; max 25 per call and
the cap is not overridable. mailbox is REQUIRED and must be the folder url from
the read that produced the ids, passed back VERBATIM — not one of the canonical
names: Mail files a deleted message in its OWNING account's Trash, and a unified
name ("inbox") cannot say which account that is. A Gmail label folder is refused
(a delete there is not a delete of the message, #287); use the message's
[Gmail]/All Mail folder.
Returns {op, receipt, count, succeeded, targets, destination, backup_dir, undo} —
keep receipt to undo the batch. Each copy is deleted by Mail's internal id and
verified by the account Trash's count rising, or every one of its copies' source
reference going gone within a bounded wait (12 checks, 0.5s apart — up to 5.5s) —
Mail's delete clears the source ASYNCHRONOUSLY, so "gone from the source" alone is
not a signal that can be read straight after the call. A message with several
stored copies gets every one of them trashed. If the call times out mid-batch,
re-run the same batch or
mail_undo the receipt. Needs Automation access for Mail, plus Full Disk Access
to locate each message's file for the backup. |
| mail_duplicatesA | Where Mail is storing redundant copies of the same message — a REPORT, read-only. Mail accumulates duplicate rows (a UI drag copies rather than moves, Gmail shows one
message under a label and All Mail, migrations leave copies on two accounts). The
read tools already hide them — mail_search/mail_thread/mail_overview report
result per Message-ID — so this exists to show what is still physically there.
Returns {redundant, mailboxes, worst, cross_account, note}: mailboxes is
{mailbox_url, total, distinct_, redundant} per mailbox worst-first, worst names
individual messages with the most copies ({mailbox_url, id, subject, copies}), and
cross_account counts rows whose Message-ID also exists under another account.
This tool CANNOT delete anything. Cleaning up is macos-apps-mcp dedupe-mail, run
a terminal by the user — thousands of deletes is a job a human starts, not a tool
call. Tell them that command rather than proposing per-message deletes.
Fast and read-only, straight from Mail's index at rest (so it may lag Mail by a few
minutes and never launches Mail). Needs Full Disk Access. |
| mail_undoA | Undo one recoverable Mail operation by its receipt id (from move_mail's
result, or from audit). A move is undone by moving the messages back to the exact
mailbox each came from — recorded per message before the original ran. dry_run DEFAULTS TO TRUE: preview which messages would be restored, then pass
dry_run=False. The undo is itself backed up, logged and verified, and returns its
own receipt — so an undo can be undone. A receipt for an operation with no
destination mailbox cannot be replayed; the error names the directory holding the
preserved message bytes for manual re-import.
Undoing a batch moved into a mailbox that already held pre-existing copies of the
same Message-ID moves those copies back too — the by-ID act moves every copy of a
Message-ID, and undo is an ordinary move. A receipt whose targets recorded
unknown (a timeout mid-act) is replayed the same as ok.
A move into a Gmail label folder or a unified mailbox name cannot be replayed; the
error names the source folder and how to restore by hand.
Destructive (it moves mail); needs Automation access for Mail and Full Disk Access.
|
| update_mail_statusA | Mark Mail messages read/unread and flag/unflag them, optionally with a colour. ids are RFC822 message-ids from a mail read, comma-separated; max 25 per call.
mailbox is OPTIONAL and is the disambiguator: pass the folder value from the
SAME read back VERBATIM (an opaque imap://<uuid>/<path> token) and the whole batch
is addressed directly; omit it and each id is resolved through Mail's index (Full
Disk Access), which also lets one call span several mailboxes.
At least one of read, flagged or flag_color is required. flag_color is a
name — red/orange/yellow/green/blue/purple/grey — and setting one implies flagged.
dry_run defaults to FALSE: this changes two booleans, destroys nothing, and
re-issuing it with the opposite value is the undo — so it does not use the backup
plane that move_mail does. Each message is re-read after the write, so the per-id
results report what actually persisted. Destructive (it modifies stored messages);
needs Automation access for Mail.
|
| notesA | Search Notes by title, snippet and note body. Returns {results, truncated?,
plane?, coverage?}: results are pointers (id + snippet); title/snippet hits come
first, body-only hits after. Body words split at spaces and punctuation only, so a
word inside unspaced Chinese or Japanese text is found in a title or snippet, never
in a body. Read-only. Fast path reads NoteStore.sqlite (needs Full Disk Access) and
searches bodies in memory, never on disk, skipping locked notes; without it,
degrades to an Automation (Notes) title search, and plane/coverage say so.
Automation access is the floor. See notes_all, note_bodies. |
| notes_allA | List the 25 newest notes as pointers (id + "Account / Folder" + snippet),
excluding Recently Deleted. Read-only. Fast path reads NoteStore.sqlite (needs Full
Disk Access); degrades to Automation (Notes) enumeration without it (very large
libraries can hit the osascript timeout, all-or-nothing). See note_bodies. |
| note_bodiesA | Hydrate plaintext bodies for up to 50 note ids (opt-in; search stays
pointer-only). Returns [{"id", "body"}]; unknown ids are silently skipped.
Read-only; needs Automation access for Notes. Get ids from notes / notes_all. |
| safari_tabsA | List open Safari tabs as pointers (url + title). Bounded to 50.
Read-only; needs Automation access for Safari. See safari_open to open a URL. |
| music_searchA | Search the Music library + playlists as pointers. query optional
name/artist/album substring (empty lists all, bounded). Read-only; needs Automation
access for Music. Pointers only (id = persistent ID); no audio plays. |
| now_playingA | Current Music player state + track (name/artist/album/id/position/duration), or
{"state": "stopped"}. Read-only; needs Automation access for Music. |
| photosA | Search Photos by one string, matched (case, accent and smart-quote blind)
against original filename, filename, title, description, keywords, place name,
person names and labels. Returns {results, truncated?}: pointers (id = the Photos
uuid, summary = title · filename · date), newest first, at most 25; truncated
means the 25 cap was reached and there may be more. Hidden and Recently Deleted
photos never match. Pass the id to photo_info or export_photo. Reads the library at
rest through osxphotos, so it needs Full Disk Access (not Automation). Read-only;
never returns image bytes or thumbnails. |
| photo_albumsA | List Photos albums as pointers: id = the album uuid, summary = title (n photos), folder = the album's folder path (/ joined) when it sits in a folder.
Returns
{results, truncated?}, at most 100, ordered by folder then title; same-title
albums stay separate. Needs Full Disk Access (osxphotos reads the library at rest).
Read-only; the albums' photos are not listed — search with photos. |
| photo_infoA | Metadata for one photo by id: id, filename, title, description, kind (photo,
video, live photo), edited, taken/added/modified (ISO-8601), latitude, longitude,
place, persons, camera_make, camera_model, lens, focal_length, aperture,
shutter_speed, iso, width, height. Photo ids come from photos; the old
<UUID>/L0/001 form is accepted too. Reads the Photos library at rest through
osxphotos, so it needs Full Disk Access (not Automation). Read-only; never returns
image bytes, thumbnails or file paths. An unknown or Recently Deleted id raises; a
Hidden one is refused (unhide it in Photos first). |
| export_photoA | Write one photo out as a still image file (Full Disk Access). Writes the edited still, the image as Photos shows it; the original when there is
no edit. id is a photo id from photos (the old
<UUID>/L0/001 form is accepted). dest_dir must be inside the allowed root
(~/Downloads by default; MACOS_APPS_FILE_ROOT moves it) and is created if needed;
a path outside it is REFUSED. The filename is derived from the original name plus
the first 8 characters of the id, and an existing file is never overwritten.
Files over 250 MiB are refused. Reads the library at rest, so it never launches Photos and needs no Automation. A
photo whose file is only in iCloud comes back status: "absent" with a reason:
nothing is downloaded. Videos are refused; a Live Photo exports its still only and
says skipped: "live photo motion". Hidden and Recently Deleted ids are refused. Returns {id, status: "written", path, bytes, edited, dest_dir}.
Additive: writes a new file, never modifies the Photos library. |
| messages_chatsA | List Messages conversations (id + name). No content; sending isn't supported.
Read-only; needs Automation access for Messages. |
| messages_searchA | Search Messages by text content (chat.db, read-only), newest first. Pointers:
id=message guid, summary=[date] sender: snippet. Needs Full Disk Access (raises a
typed error if not granted). Text-only for now; sending isn't supported. |
| messages_withA | Recent Messages by phone or email (chat.db, read-only), newest-first.
contact a phone number or email; country an optional calling code or 2-letter
region (e.g. '+32' or 'BE') to resolve a national number — default from the Mac's
locale, never +1. Needs Full Disk Access (raises a typed error if not granted). |
| message_bodyA | Full text of one Message by id (chat.db, read-only). Decodes the attributedBody
typedstream when message.text is NULL (the modern norm); returns "" for a message
with no text. Needs Full Disk Access. id is a message guid from messages_search or
messages_with. |
| shortcutsA | List/search Shortcuts by name (empty lists all). Pointers: id = the shortcut's
stable UUID (survives renames), summary = name, deeplink = shortcuts://run-shortcut.
Read-only; uses the Shortcuts CLI (no TCC prompt). See run_shortcut to invoke. |
| create_reminderA | Create a reminder. due/start ISO datetime — naive = local time, call now()
first; priority 0–9; recurrence an RRULE with FREQ, INTERVAL, COUNT, UNTIL,
BYDAY (ordinals such as 2TU or -1FR for monthly and yearly), BYMONTHDAY,
BYMONTH, BYYEARDAY, BYSETPOS; BYWEEKNO, BYHOUR, BYMINUTE, BYSECOND and WKST are
refused.
This tool cannot set tags or make a subtask (no public API; read-only via
reminders()).
Side effect (creates); needs EventKit (Reminders) access. Target a list via
list_name — a list name OR a list Pointer id (from reminder_lists). An ambiguous
name is refused (with the candidate ids listed), never guessed. |
| create_reminder_listA | Create a reminder list in the default Reminders account. An exact-name duplicate
is refused (it would make list_name= ambiguous). Returns the list pointer (id +
name); use the id as list_name=. Side effect (creates); needs EventKit
(Reminders) access. |
| update_reminderA | Update a reminder by id (full replace). due/start ISO (naive = local).
recurrence: RRULE to set ('none' to stop repeating) with FREQ, INTERVAL, COUNT,
UNTIL, BYDAY (ordinals such as 2TU or -1FR for monthly and yearly), BYMONTHDAY,
BYMONTH, BYYEARDAY, BYSETPOS; BYWEEKNO, BYHOUR, BYMINUTE, BYSECOND and WKST are
refused. REQUIRED (rule or 'none')
when the target reminder repeats — omitting it is refused so a rename can't
silently kill the series.
This tool cannot set tags or make a subtask (no public API; read-only via
reminders()).
Side effect (full-replace update); needs EventKit (Reminders) access. id from
reminders. |
| complete_reminderA | Mark a reminder complete by id. Completing a parent leaves its subtasks open
(Reminders.app hides them under a completed parent); the result lists them under
subtasks. Side effect (completes). Requires EventKit and Full Disk Access:
the subtask check reads the Reminders store first, and when the store cannot be
read the completion is refused and nothing changes. id from reminders. |
| delete_reminderA | Delete a reminder by id. dry_run DEFAULTS TO TRUE — previews the reminder that
WOULD be deleted (pointer, no mutation); pass dry_run=false to delete. A parent
with subtasks is refused unless with_subtasks=true, because deleting a parent
deletes its subtasks too. A subtask indented in Reminders in the last few minutes
may not be visible yet.
Destructive; needs EventKit and Full Disk Access (the subtask check reads the
Reminders store). id from reminders. |
| create_eventA | Create an event. start/end ISO datetime — naive = local time, call now()
first; recurrence an RRULE with FREQ, INTERVAL, COUNT, UNTIL, BYDAY (ordinals
such as 2TU or -1FR for monthly and yearly), BYMONTHDAY, BYMONTH, BYYEARDAY,
BYSETPOS; BYWEEKNO, BYHOUR, BYMINUTE, BYSECOND and WKST are refused. all_day
takes a DATE
(2026-07-01); a timestamp with a UTC offset is rejected. A start that does not
match its recurrence is accepted (RFC 5545 counts it as the first occurrence) and
the result's summary says so.
alarms are minutes before the start, as in the Google Calendar API. For an all-day
event the offset counts from local midnight: -540 is 09:00 on the day, 900 is
09:00 the day before. On a DST-change day the alert shifts an hour, the same as
Calendar's own alerts. At most 5.
Side effect (creates); needs EventKit (Calendar) access. Target a calendar via
calendar — a calendar name OR a calendar Pointer id (from calendars). An ambiguous
name is refused (with the candidate ids listed), never guessed. |
| update_eventA | Update an event by id (full replace). start/end ISO — naive = local time.
recurrence an RRULE with FREQ, INTERVAL, COUNT, UNTIL, BYDAY (ordinals such as
2TU or -1FR for monthly and yearly), BYMONTHDAY, BYMONTH, BYYEARDAY, BYSETPOS;
BYWEEKNO, BYHOUR, BYMINUTE, BYSECOND and WKST are refused. all_day takes a
DATE (2026-07-01); a timestamp with a UTC offset
is rejected. A start that does not match its recurrence is accepted (RFC 5545
counts it as the first occurrence) and the result's summary says so.
alarms are minutes before the start, as in the Google Calendar API. For an all-day
event the offset counts from local midnight: -540 is 09:00 on the day, 900 is
09:00 the day before. On a DST-change day the alert shifts an hour, the same as
Calendar's own alerts. At most 5. Omit alarms to keep the event's alarms; []
removes them all.
span REQUIRED if the target is recurring: 'this-event' (only this occurrence) or
'future-events' (this + all later); ignored for single events.
Side effect (full-replace update); needs EventKit (Calendar) access. id from
events. |
| delete_eventA | Delete a calendar event by id. span REQUIRED if the target is recurring:
'this-event' (only this occurrence) or 'future-events' (this + all later); ignored
for single events. dry_run DEFAULTS TO TRUE — previews the event that WOULD be
deleted (pointer, no mutation); pass dry_run=false to delete.
Destructive; needs EventKit (Calendar) access. id from events. |
| delete_noteA | Delete a note by id → Recently Deleted (recoverable ~30 days). Destructive.
Pass expect_title to verify the target before deleting (content-verify first).
dry_run DEFAULTS TO TRUE — previews the note that WOULD be deleted (pointer, no
mutation); pass dry_run=false to delete.
Needs Automation access for Notes. id from notes / notes_all. |
| create_noteA | Create a note and return its STABLE x-coredata id (unique in the ecosystem —
immediately usable with note_bodies). title/body are plaintext (escaped, so
markup is inert); folder an existing folder name (across accounts) or omit for the
default folder — an unknown/ambiguous name is refused. Verified after write (#49).
Side effect (creates); needs Automation access for Notes (verify read-back also uses
Full Disk Access, falling back to Automation). |
| update_noteA | Update a note by id (full-replace title+body); the stable id is preserved and
verified (#49). dry_run DEFAULTS TO TRUE — previews the CURRENT title and body
size against the NEW ones (reads Notes; makes no write); pass dry_run=false to
overwrite. title/body plaintext (escaped). folder must be omitted — update
cannot move a note between folders and refuses a non-None folder loudly.
Side effect (full-replace update); needs Automation access for Notes. id from
notes / notes_all / create_note. |
| create_contactA | Create a contact (given/family name + organization).
Side effect (creates); needs Automation access for Contacts. |
| run_shortcutA | Run a Shortcut by name OR its UUID id (from shortcuts — the id is unambiguous
across renames/duplicate names); optional input_text piped in. Returns a pointer
citing the run + a bounded output snippet. dry_run=True resolves the shortcut
and reports what WOULD run without running anything. Side effect (runs arbitrary
automation the user owns, which may reach beyond this machine — a shortcut can
call web services); uses the Shortcuts CLI (no TCC prompt). |
| safari_openA | Open a URL in a new Safari tab; adds https:// if no scheme (http/https only).
Side effect (opens a tab); needs Automation access for Safari. See safari_tabs. |
| music_controlA | Control Music playback: action in play|pause|playpause|next|previous. Additive,
reversible player-state change; needs Automation access for Music. Returns the
resulting now-playing state. |
| play_playlistA | Play a Music playlist by its persistent id (from music_search). Additive,
reversible; needs Automation access for Music. Returns the resulting now-playing
state. |
| set_volumeA | Set the Music app sound volume (0–100). Additive, reversible; needs Automation
access for Music. Returns the resulting now-playing state. |
| set_modeA | Set Music shuffle or repeat: mode in shuffle|repeat, on=true/false (repeat
on→all, off→off). Additive, reversible; needs Automation access for Music. Returns
the resulting now-playing state. |