From axiom
References iOS Contacts API including CNContactStore for authorization, fetching with predicates, unified contacts, containers/groups, and change notifications.
npx claudepluginhub charleswiltgen/axiom --plugin axiomThis skill uses the workspace's default tool permissions.
The Contacts framework provides programmatic access to the system contact database. ContactsUI provides system view controllers for contact selection and display. ContactProvider enables apps to expose their own contacts to the system.
Provides Ktor server patterns for routing DSL, plugins (auth, CORS, serialization), Koin DI, WebSockets, services, and testApplication testing.
Conducts multi-source web research with firecrawl and exa MCPs: searches, scrapes pages, synthesizes cited reports. For deep dives, competitive analysis, tech evaluations, or due diligence.
Provides demand forecasting, safety stock optimization, replenishment planning, and promotional lift estimation for multi-location retailers managing 300-800 SKUs.
The Contacts framework provides programmatic access to the system contact database. ContactsUI provides system view controllers for contact selection and display. ContactProvider enables apps to expose their own contacts to the system.
Platform: iOS 9.0+, iPadOS 9.0+, macOS 10.11+, Mac Catalyst 13.1+, watchOS 2.0+, visionOS 1.0+
The primary gateway for contact data. "Fetch methods perform I/O — avoid using the main thread."
// Check status (static method)
let status = CNContactStore.authorizationStatus(for: .contacts)
// Returns: .notDetermined, .restricted, .denied, .authorized, .limited (iOS 18+)
// Request access
let store = CNContactStore()
try await store.requestAccess(for: .contacts) // Returns Bool
Info.plist required: NSContactsUsageDescription (crash without it).
Special entitlement: com.apple.developer.contacts.notes — required to read/write note field. Requires Apple approval.
// Single contact by identifier
let contact = try store.unifiedContact(
withIdentifier: identifier,
keysToFetch: keys
)
// Search by predicate
let contacts = try store.unifiedContacts(
matching: predicate,
keysToFetch: keys
)
// Current user's card
let me = try store.unifiedMeContact(withKeysToFetch: keys)
// Memory-efficient enumeration
let request = CNContactFetchRequest(keysToFetch: keys)
request.predicate = predicate // Optional filter
request.sortOrder = .userDefault // .none, .givenName, .familyName, .userDefault
try store.enumerateContacts(with: request) { contact, stop in
// stop.pointee = true to break early
}
CNContact.predicateForContacts(matchingName: "John")
CNContact.predicateForContacts(matchingEmailAddress: "john@example.com")
CNContact.predicateForContacts(matching: CNPhoneNumber(stringValue: "+1555..."))
CNContact.predicateForContacts(withIdentifiers: [id1, id2])
CNContact.predicateForContactsInGroup(withIdentifier: groupId)
CNContact.predicateForContactsInContainer(withIdentifier: containerId)
store.containers(matching: predicate) // [CNContainer]
store.groups(matching: predicate) // [CNGroup]
store.defaultContainerIdentifier // String (property, not method)
CNContainer types: .local, .exchange, .cardDAV, .unassigned
store.currentHistoryToken // Data? — save for incremental sync
NotificationCenter.default.addObserver(
forName: .CNContactStoreDidChange, object: nil, queue: .main
) { _ in
// Refetch visible contacts
}
let saveRequest = CNSaveRequest()
saveRequest.add(contact, toContainerWithIdentifier: nil) // nil = default
saveRequest.update(contact)
saveRequest.delete(contact.mutableCopy() as! CNMutableContact)
try store.execute(saveRequest)
You MUST specify which properties to fetch. Accessing an unfetched property throws CNContactPropertyNotFetchedException.
| Key | Property |
|---|---|
CNContactIdentifierKey | identifier |
CNContactGivenNameKey | givenName |
CNContactFamilyNameKey | familyName |
CNContactMiddleNameKey | middleName |
CNContactNamePrefixKey | namePrefix |
CNContactNameSuffixKey | nameSuffix |
CNContactNicknameKey | nickname |
CNContactOrganizationNameKey | organizationName |
CNContactJobTitleKey | jobTitle |
CNContactDepartmentNameKey | departmentName |
CNContactPhoneNumbersKey | phoneNumbers |
CNContactEmailAddressesKey | emailAddresses |
CNContactPostalAddressesKey | postalAddresses |
CNContactUrlAddressesKey | urlAddresses |
CNContactSocialProfilesKey | socialProfiles |
CNContactInstantMessageAddressesKey | instantMessageAddresses |
CNContactBirthdayKey | birthday |
CNContactNonGregorianBirthdayKey | nonGregorianBirthday |
CNContactDatesKey | dates |
CNContactNoteKey | note (requires entitlement) |
CNContactImageDataKey | imageData |
CNContactThumbnailImageDataKey | thumbnailImageData |
CNContactImageDataAvailableKey | imageDataAvailable |
CNContactRelationsKey | contactRelations |
CNContactTypeKey | contactType (.person, .organization) |
// All keys needed for name display (locale-aware)
CNContactFormatter.descriptorForRequiredKeys(for: .fullName)
CNContactFormatter.descriptorForRequiredKeys(for: .phoneticFullName)
// All keys needed for vCard export
CNContactVCardSerialization.descriptorForRequiredKeys()
Always prefer formatter descriptors over manual key lists for name display.
Mutable subclass of CNContact. Not thread-safe — use immutable CNContact for cross-thread access.
let contact = CNMutableContact()
contact.givenName = "Jane"
contact.familyName = "Appleseed"
contact.organizationName = "Apple Inc."
contact.jobTitle = "Engineer"
// Phone numbers
contact.phoneNumbers = [
CNLabeledValue(label: CNLabelPhoneNumberMobile,
value: CNPhoneNumber(stringValue: "+15551234567")),
CNLabeledValue(label: CNLabelWork,
value: CNPhoneNumber(stringValue: "+15559876543"))
]
// Email addresses
contact.emailAddresses = [
CNLabeledValue(label: CNLabelHome, value: "jane@example.com" as NSString),
CNLabeledValue(label: CNLabelWork, value: "jane@apple.com" as NSString)
]
// Postal addresses
let address = CNMutablePostalAddress()
address.street = "1 Apple Park Way"
address.city = "Cupertino"
address.state = "CA"
address.postalCode = "95014"
address.country = "United States"
contact.postalAddresses = [CNLabeledValue(label: CNLabelWork, value: address)]
// Birthday
contact.birthday = DateComponents(year: 1990, month: 6, day: 15)
// Photo
contact.imageData = imageData
Set strings/arrays to empty, other properties to nil.
"You may modify only those properties whose values you fetched from the contacts database."
Batch operations for contacts, groups, and subgroups.
Platform: iOS 9.0+, iPadOS 9.0+, macOS 10.11+, Mac Catalyst 13.1+ (no watchOS)
let request = CNSaveRequest()
request.add(contact, toContainerWithIdentifier: containerId) // nil = default
request.update(contact)
request.delete(contact)
request.add(group, toContainerWithIdentifier: containerId)
request.update(group)
request.delete(group)
request.addMember(contact, to: group)
request.removeMember(contact, from: group)
request.addSubgroup(subgroup, to: parentGroup)
request.removeSubgroup(subgroup, from: parentGroup)
| Property | Type | Notes |
|---|---|---|
shouldRefetchContacts | Bool | Refetch added/updated contacts post-execution |
transactionAuthor | String? | Identifies who made the change (for change history filtering) |
try store.execute(request)
Concurrency: "Last change wins" for overlapping concurrent changes.
Locale-aware name formatting.
let formatter = CNContactFormatter()
// Format name
let name = formatter.string(from: contact) // String?
let name = CNContactFormatter.string(from: contact, style: .fullName)
// Attributed string variants
let attributed = formatter.attributedString(from: contact)
// Locale information
let order = CNContactFormatter.nameOrder(for: contact) // .givenNameFirst, .familyNameFirst
let delimiter = CNContactFormatter.delimiter(for: contact) // Locale-appropriate separator
| Style | Example |
|---|---|
.fullName | "Jane Appleseed" or "Appleseed Jane" (per locale) |
.phoneticFullName | Phonetic representation |
// Export contacts to vCard data
let data = try CNContactVCardSerialization.data(with: contacts)
// Import contacts from vCard data
let contacts = try CNContactVCardSerialization.contacts(with: data)
// Required keys for export
let keys = CNContactVCardSerialization.descriptorForRequiredKeys()
Lets users pick contacts without requiring app-level authorization. App receives one-time snapshot.
let picker = CNContactPickerViewController()
picker.delegate = self
picker.displayedPropertyKeys = [CNContactPhoneNumbersKey, CNContactEmailAddressesKey]
present(picker, animated: true)
// Which contacts are selectable
picker.predicateForEnablingContact = NSPredicate(
format: "phoneNumbers.@count > 0"
)
// Auto-select whole contact (skip property selection)
picker.predicateForSelectionOfContact = NSPredicate(
format: "emailAddresses.@count > 0"
)
// Which properties can be selected individually
picker.predicateForSelectionOfProperty = NSPredicate(
format: "key == 'phoneNumbers'"
)
Gotcha: Changing predicates only takes effect before the view is presented.
func contactPicker(_ picker: CNContactPickerViewController,
didSelect contact: CNContact) { }
func contactPicker(_ picker: CNContactPickerViewController,
didSelect contacts: [CNContact]) { } // Multi-selection
func contactPicker(_ picker: CNContactPickerViewController,
didSelect contactProperty: CNContactProperty) { }
func contactPickerDidCancel(_ picker: CNContactPickerViewController) { }
Display a single contact with three initialization modes:
// Existing contact
let vc = CNContactViewController(for: contact)
// Unknown contact (partial data)
let vc = CNContactViewController(forUnknownContact: partialContact)
// New contact
let vc = CNContactViewController(forNewContact: nil)
// Display mode
vc.allowsEditing = true
vc.allowsActions = true // Call, message, email buttons
vc.displayedPropertyKeys = [CNContactPhoneNumbersKey]
vc.highlightProperty(withKey: CNContactPhoneNumbersKey, identifier: nil)
SwiftUI component for privacy-conscious contact access.
ContactAccessButton(queryString: searchText) { identifiers in
let contacts = await fetchContacts(withIdentifiers: identifiers)
}
| Value | Shows |
|---|---|
.defaultText | Default text |
.email | Email address |
.phone | Phone number |
.font(.system(weight: .bold))
.foregroundStyle(.gray)
.tint(.green)
.contactAccessButtonCaption(.phone)
.contactAccessButtonStyle(ContactAccessButton.Style(imageWidth: 30))
Button only grants access when:
Modal sheet for managing limited access contact set. For bulk or non-immediate use cases.
@State private var isPresented = false
Button("Share More Contacts") {
isPresented.toggle()
}
.contactAccessPicker(isPresented: $isPresented) { identifiers in
// identifiers: [String] — newly permitted contacts only
let contacts = await fetchContacts(withIdentifiers: identifiers)
}
Difference from CNContactPickerViewController: contactAccessPicker changes persistent access. CNContactPickerViewController provides one-time snapshots.
Enables apps to expose contacts to the system Contacts ecosystem from third-party sources.
ContactProviderManagerlet manager = try ContactProviderManager(domainIdentifier: "com.myapp.contacts")
try await manager.enable() // Async — may prompt user
try await manager.disable() // Deactivate
try await manager.reset() // Clear all provider contacts
try await manager.invalidate() // Terminate extension
try await manager.signalEnumerator(for: .default) // Trigger enumeration
manager.isEnabled // Bool — activation state
Cannot be used in app extensions — main app only.
@main
class Provider: ContactProviderExtension {
func configure(for domain: ContactProviderDomain) {
// Setup data access
}
func enumerator(for collection: ContactItem.Identifier)
-> ContactItemEnumerator {
return MyEnumerator()
}
func invalidate() async throws {
// Cleanup
}
}
Info.plist: Extension point com.apple.contact.provider.extension
Two patterns:
ContactItemContentObserverContactItemChangeObserver and ContactItemSyncAnchorclass MyEnumerator: ContactItemEnumerator {
func enumerateContent(
in page: ContactItemPage,
for observer: ContactItemContentObserver
) {
let contact = CNMutableContact()
contact.givenName = "Jane"
contact.familyName = "Appleseed"
let item = ContactItem.contact(contact, ContactItem.Identifier("jane-001"))
observer.didEnumerate([item])
observer.didFinishEnumeratingContent(upTo: generationMarker)
}
func enumerateChanges(
startingAt anchor: ContactItemSyncAnchor,
for observer: ContactItemChangeObserver
) {
// Incremental updates since anchor
observer.didFinishEnumeratingChanges(upTo: newAnchor)
}
}
| Code | Meaning |
|---|---|
featureNotAvailable | Framework not available |
deniedByUser | User rejected |
extensionNotFound | Extension not registered |
enumerationTimeout | Extension too slow |
cannotEnumerate | Enumeration failed |
pageExpired | Content page expired |
changeAnchorExpired | Sync anchor expired |
itemsLimitReached | Too many contacts |
let request = CNChangeHistoryFetchRequest()
request.startingToken = savedToken // nil = full fetch
request.includeGroupChanges = false // Default NO
request.mutableObjects = false // Default NO
request.shouldUnifyResults = true // Default YES
request.additionalContactKeyDescriptors = [
CNContactFormatter.descriptorForRequiredKeys(for: .fullName)
]
request.excludedTransactionAuthors = [Bundle.main.bundleIdentifier!]
// Required
func visit(_ event: CNChangeHistoryDropEverythingEvent) // Full re-sync
func visit(_ event: CNChangeHistoryAddContactEvent) // New contact
func visit(_ event: CNChangeHistoryUpdateContactEvent) // Modified
func visit(_ event: CNChangeHistoryDeleteContactEvent) // Deleted
// Optional (when includeGroupChanges = true)
func visit(_ event: CNChangeHistoryAddGroupEvent)
func visit(_ event: CNChangeHistoryUpdateGroupEvent)
func visit(_ event: CNChangeHistoryDeleteGroupEvent)
func visit(_ event: CNChangeHistoryAddMemberToGroupEvent)
func visit(_ event: CNChangeHistoryRemoveMemberFromGroupEvent)
func visit(_ event: CNChangeHistoryAddSubgroupToGroupEvent)
func visit(_ event: CNChangeHistoryRemoveSubgroupFromGroupEvent)
Must use visitor pattern — do NOT use isKindOfClass: to determine event type.
Gotcha: enumeratorForChangeHistoryFetchRequest:error: is Objective-C only — unavailable in Swift.
Token expiration: Returns DropEverything + Add events for all contacts — same code handles full and incremental sync.
Transaction authors: Use reverse-domain notation (bundle identifier). Filters results but doesn't provide attribution.
| Category | Code | Meaning |
|---|---|---|
| Authorization | authorizationDenied | No permission |
| Authorization | featureDisabledByUser | Feature turned off |
| Data | recordDoesNotExist | Contact/group deleted |
| Data | recordNotWritable | Read-only contact |
| Data | insertedRecordAlreadyExists | Duplicate insert |
| Validation | validationTypeMismatch | Wrong value type |
| Validation | validationMultipleErrors | Multiple validation failures |
| History | changeHistoryExpired | Sync token expired |
| History | changeHistoryInvalidAnchor | Bad sync anchor |
| History | changeHistoryInvalidFetchRequest | Invalid request |
Error userInfo provides: affectedRecords, affectedRecordIdentifiers, keyPaths.
| API | iOS | macOS | watchOS | visionOS |
|---|---|---|---|---|
| CNContactStore | 9.0+ | 10.11+ | 2.0+ | 1.0+ |
| Limited access | 18.0+ | — | — | — |
| CNContactPickerViewController | 9.0+ | (Catalyst 13.1+) | — | 1.0+ |
| CNContactViewController | 9.0+ | (Catalyst 13.1+) | — | 1.0+ |
| ContactAccessButton | 18.0+ | — | — | — |
| contactAccessPicker | 18.0+ | — | — | — |
| ContactProvider | 18.0+ | — | — | — |
| CNChangeHistoryFetchRequest | 13.0+ | 10.15+ | — | 1.0+ |
| CNSaveRequest | 9.0+ | 10.11+ | — | 1.0+ |
WWDC: 2024-10121
Docs: /contacts, /contacts/cncontactstore, /contacts/cnmutablecontact, /contactsui, /contactsui/cncontactpickerviewcontroller, /contactprovider, /technotes/tn3149
Skills: contacts, eventkit-ref, privacy-ux