| name | contacts-framework |
| description | Use BEFORE writing any code that touches Contacts.app data. Covers the Contacts.framework / PyObjC primary surface, the AppleScript fallback path for note + modification-date access, the TCC authorization flow, and the gotchas that aren't obvious from Apple's docs (entitlement-gated keys, raw label tokens, vCard 3.0-only export). Read together with `docs/research/contacts-api-gap-analysis.md` for the empirical basis. |
Apple Contacts via Contacts.framework
Primary surface: Contacts.framework via pyobjc-framework-Contacts.
Fallback surface: AppleScript via osascript, used only for note and modification/creation timestamps.
This decision was made empirically in Phase 0. Do not revisit without new evidence.
Object model — Contacts.framework
CNContactStore — top-level handle, one per process is fine
├── containers (CNContainer) — iCloud, Gmail, Exchange, On My Mac
├── contacts (CNContact) — the unified address book (read-only objects)
└── groups (CNGroup) — owned by exactly one container
CNMutableContact — for create/update; pass to CNSaveRequest
CNMutableGroup — for group create/update
CNSaveRequest — batched mutation; submit via store.executeSaveRequest_error_
CNLabeledValue — wraps {label, value} for phones/emails/addresses
CNPhoneNumber — value type for phones
CNPostalAddress — value type for postal addresses
CNContactVCardSerialization — vCard 3.0 import + export (3.0 only on output, accepts 4.0 input)
The fetch-keys gotcha (read this first)
Every CNContact you fetch is lazy about properties — you must declare upfront which keys you'll access. Touching an undeclared key raises CNPropertyNotFetchedException. Always pass an explicit keysToFetch list.
from Contacts import (
CNContactStore, CNContactFetchRequest,
CNContactGivenNameKey, CNContactFamilyNameKey,
CNContactPhoneNumbersKey, CNContactEmailAddressesKey,
)
store = CNContactStore.alloc().init()
keys = [
CNContactGivenNameKey, CNContactFamilyNameKey,
CNContactPhoneNumbersKey, CNContactEmailAddressesKey,
]
req = CNContactFetchRequest.alloc().initWithKeysToFetch_(keys)
contacts = []
def collect(contact, stop_ptr):
contacts.append(contact)
store.enumerateContactsWithFetchRequest_error_usingBlock_(req, None, collect)
For vCard export, use CNContactVCardSerialization.descriptorForRequiredKeys() instead of an explicit list — the descriptor already includes every key vCard needs:
from Contacts import CNContactVCardSerialization
desc = CNContactVCardSerialization.descriptorForRequiredKeys()
req = CNContactFetchRequest.alloc().initWithKeysToFetch_([desc])
TCC authorization
Contacts is a TCC-protected data class. Always check status before doing anything else, and on every tool entry (the user can revoke mid-process).
from Contacts import CNContactStore, CNEntityTypeContacts
CN_STATUS = {
0: "notDetermined",
1: "restricted",
2: "denied",
3: "authorized",
4: "limited",
}
def check_authorization() -> dict:
status = CNContactStore.authorizationStatusForEntityType_(CNEntityTypeContacts)
if status in (3, 4):
return {"success": True, "status": CN_STATUS[status]}
return {
"success": False,
"error": f"Contacts access not granted (status={CN_STATUS[status]}).",
"error_type": "authorization_denied",
"remediation": "Open System Settings → Privacy & Security → Contacts and grant access.",
}
The first call to requestAccessForEntityType_completionHandler_ triggers the system prompt. The completion handler runs on a background queue; for synchronous CLI flow, prefer surfacing the unauthorized state to the LLM with error_type: "authorization_denied" and let the user grant manually, then retry.
Predicates — the right way to filter
Contacts.framework ships several pre-built predicates. Always prefer these over loops:
from Contacts import CNContact
pred = CNContact.predicateForContactsMatchingName_("John")
pred = CNContact.predicateForContactsMatchingPhoneNumber_(phone)
pred = CNContact.predicateForContactsMatchingEmailAddress_(email)
pred = CNContact.predicateForContactsInGroupWithIdentifier_(group_uuid)
pred = CNContact.predicateForContactsInContainerWithIdentifier_(container_uuid)
results, err = store.unifiedContactsMatchingPredicate_keysToFetch_error_(
pred, keys, None
)
For organization-name matching, there's no canned predicate — use a custom NSPredicate. Document the workaround as you write it.
Phone-predicate fetch-keys gotcha (undocumented): predicateForContactsMatchingPhoneNumber: silently returns zero results unless CNContactPhoneNumbersKey is in keysToFetch. The unification step apparently skips contacts whose matching field wasn't requested. Empirically verified 2026-05-08 against macOS 14 + a contact created and immediately searched in the same store. Email currently matches without CNContactEmailAddressesKey but include it anyway for symmetry — Apple may tighten the rule. Bottom line: when filtering by a multi-valued field, always include that field's storage key in keysToFetch.
Writes — CNSaveRequest
Mutations go through CNSaveRequest. Build the request, then submit it once.
from Contacts import CNMutableContact, CNSaveRequest, CNLabeledValue, CNPhoneNumber
new_contact = CNMutableContact.alloc().init()
new_contact.setGivenName_("Jane")
new_contact.setFamilyName_("Smith")
phone = CNLabeledValue.labeledValueWithLabel_value_(
"_$!<Mobile>!$_",
CNPhoneNumber.phoneNumberWithStringValue_("+15550123"),
)
new_contact.setPhoneNumbers_([phone])
save_req = CNSaveRequest.alloc().init()
save_req.addContact_toContainerWithIdentifier_(new_contact, None)
ok, err = store.executeSaveRequest_error_(save_req, None)
Multiple operations on a single CNSaveRequest are atomic. Use this for bulk updates rather than looping executeSaveRequest_error_ per contact.
removeMember:fromGroup: silently no-ops (undocumented): CNSaveRequest.addMember:toGroup: works as documented, but its inverse removeMember:fromGroup: reports ok=True, err=None and does not actually remove the membership edge. Empirically verified 2026-05-09 against macOS — neither the contact form (mutable copy of unified vs. predicate-fetched), the group form (CNGroup vs. CNMutableGroup), nor a fresh fetch makes a difference. The codebase routes membership removes through AppleScript instead — _run_applescript_remove_contact_from_group in contacts_connector.py issues tell application "Contacts" … remove p from g … save end tell. If you're tempted to "fix" the asymmetry by reverting to the CN selector, run tests/integration/test_group_membership.py::test_add_then_remove_cycle against real Contacts.app first — it locks in the actual persistence behavior.
Phone label tokens (and other labels)
Apple emits raw _$!<Home>!$_ / _$!<Work>!$_ / _$!<Mobile>!$_ tokens — never the user-visible string — for built-in labels. Custom labels come back as plain strings. Translate on output for human readability:
from Contacts import CNLabeledValue
label = phone.label()
human = CNLabeledValue.localizedStringForLabel_(label)
For input, there is no inverse helper — you must build a bidirectional table mapping home → _$!<Home>!$_ and friends. This is tracked under issue #18 (v0.2.0).
What Contacts.framework can't do — and the AppleScript fallback
Two specific holes in the framework that AppleScript fills:
1. Notes (entitlement-gated)
CNContactNoteKey requires the com.apple.developer.contacts.notes entitlement, which Apple grants only to App Store apps after review. Without it:
- Adding
CNContactNoteKey to keysToFetch silently drops it
- Accessing
contact.note() raises CNPropertyNotFetchedException
- vCard exports via
CNContactVCardSerialization strip the NOTE field
AppleScript reads/writes notes without entitlement. Implemented in
contacts_connector.py
as _run_applescript_read_note(identifier) and _run_applescript_write_note(identifier, note).
Exposed via get_contact(identifier, include_note=True) (read) and
update_contact(identifier, note=...) (write) — consolidated in v0.5.0 (#98)
from the previously-standalone read_note / write_note tools that shipped
in v0.2.0 (#19).
Identifier format — empirically verified during #19: AppleScript's
id of person returns the full <UUID>:ABPerson form, identical to CN's
unifiedContact.identifier. Do not strip the :ABPerson suffix. Bare
UUIDs produce "Invalid index. (-1719)" from whose id is "..." lookups.
The connector's _is_not_found_error regex maps both Can't get (curly
apostrophe — what AppleScript actually emits) and Invalid index to
ContactsNotFoundError.
Persistence — _run_applescript_write_note issues a trailing save
inside the tell application "Contacts" block. Without it, edits sit in
Contacts.app's in-memory state and don't reach disk; this is locked in by
test_save_command_persists_across_subprocesses in tests/integration/test_note_path.py.
Cold-start cost — the first osascript→Contacts.app call in a process
pays a 10–20s cold-start penalty (Contacts.app launch + Automation TCC + scripting
bridge handshake). The integration fixture warms up Contacts.app once at
session start; production callers should expect occasional first-call latency.
Escaping — only {identifier} is safe to interpolate raw. Every other
AppleScript-bound string MUST go through
escape_applescript_string()
(backslash, then double-quote — order matters).
2. Modification / creation timestamps
CNContact.creationDate and CNContact.modificationDate are accessible only via undocumented runtime selectors — not in the public CNContactKey constants. If a future macOS removes the selectors, the AppleScript fallback (creation date of person, modification date of person) is still standard scripting bridge.
Durability is verified by tests/integration/test_modification_dates.py — runs in the integration suite, asserts both selectors are bridged and return datetime objects. Failure includes a pointer to the AppleScript fallback (#33).
Multi-container behavior
store.containers() returns every active account. store.defaultContainerIdentifier() is the implicit target for addContact:toContainerWithIdentifier:None.
Container types (CNContainerType):
1 — Local (legacy "On My Mac")
2 — Exchange
3 — CardDAV (iCloud, Google, Fastmail, Nextcloud, etc.)
In practice, most macOS systems show only CardDAV containers — even iCloud is CardDAV under the hood. enumerateContactsWithFetchRequest is auto-unified across containers; no extra work needed for cross-account reads. To scope, use predicateForContactsInContainerWithIdentifier:.
Groups belong to exactly one container. If you add_contact_to_group and the contact lives in a different container than the group, the framework returns an error. Surface this clearly — don't let it bubble up as an opaque NSError.
vCard
CNContactVCardSerialization is asymmetric:
- Export emits 3.0 only, even on macOS 26 (PRODID line:
-//Apple Inc.//macOS X.Y.Z//EN)
- Import accepts both 3.0 and 4.0
NOTE field is stripped from export (entitlement-gated, same as CNContactNoteKey reads). If notes need to survive a vCard round-trip, post-process the export to inject NOTE from the AppleScript-read note text.
data, err = CNContactVCardSerialization.dataWithContacts_error_([contact], None)
vcard_text = bytes(data).decode("utf-8")
new_contacts, err = CNContactVCardSerialization.contactsWithData_error_(data, None)
Response shape — every tool
Per MCP_PLAYBOOK.md §1:
{
"success": True | False,
"contacts": [...],
"contact": {...},
"identifier": "UUID:...",
"error": "human readable description",
"error_type": "authorization_denied" | "not_found" | "validation" | "unknown",
}
Gotchas checklist (every PyObjC-touching PR)
- Did you declare
keysToFetch for every property you'll access? If not, expect CNPropertyNotFetchedException at runtime.
- Did you call
check_authorization() at tool entry? Status can change mid-process.
- Did you translate phone/email/address label tokens before returning to the LLM? Raw
_$!<...>!$_ tokens are gibberish.
- For writes: did you apply the security checklist (sanitize, validate, rate-limit, audit, test-mode)? Pattern:
MCP_PLAYBOOK.md §4 + the contacts-specific TCC step.
- For
imageData(): did you guard with imageDataAvailable() first? Otherwise you get a confusing nil result on contacts without photos.
- For vCard export: did you fetch with
descriptorForRequiredKeys() rather than a manual key list? Manual lists usually miss something.
- For CNSaveRequest: did you batch? One save per tool call, not one save per contact in a loop.
- Did you mock at
_run_cn_* for unit tests AND write an integration test that hits a real CNContactStore? Mocked tests miss real-API bugs.