---
name: axiom-eventkit
description: Use when working with ANY calendar event, reminder, EventKit permission, or EventKitUI controller. Covers access tiers (no-access, write-only, full), permission migration from pre-iOS 17, store lifecycle, reminder patterns, EventKitUI controller selection, Siri Event Suggestions, virtual conference extensions.
license: MIT
---

# EventKit — Discipline

## Core Philosophy

> "Request the minimum access needed, and only when it's needed."

**Mental model**: EventKit has three access tiers. Most apps need only the first (no access + system UI). Requesting more than you need means more users deny your request, and more code to maintain.

## When to Use This Skill

Use this skill when:
- Adding events or reminders to the user's calendar
- Choosing between EventKitUI, write-only, or full access
- Requesting calendar or reminder permissions
- Fetching, querying, or displaying existing events
- Migrating from pre-iOS 17 permission APIs
- Creating virtual conference extensions
- Implementing Siri Event Suggestions for reservations
- Debugging "access denied" or missing events

Do NOT use this skill for:
- Contacts framework questions (use **contacts**)
- General SwiftUI architecture (use **swiftui-architecture**)
- Background task scheduling (use **background-processing**)

## Related Skills

- **eventkit-ref** — Complete EventKit/EventKitUI API reference
- **contacts** — Contacts framework discipline skill
- **privacy-ux** — General iOS privacy patterns and Permission UX
- **extensions-widgets** — WidgetKit if combining calendar with widgets
- **background-processing** — If scheduling background calendar sync

---

## Access Tier Decision Tree

```dot
digraph access_decision {
    rankdir=TB;
    "What does your app need?" [shape=diamond];
    "Add single events to Calendar?" [shape=diamond];
    "Show custom create/edit UI?" [shape=diamond];
    "Read existing events/calendars?" [shape=diamond];

    "No access + EventKitUI" [shape=box, label="Tier 1: No Access\nPresent EKEventEditViewController\nNo permission prompt needed"];
    "No access + Siri Suggestions" [shape=box, label="Tier 1: No Access\nSiri Event Suggestions\nFor reservations only"];
    "Write-only access" [shape=box, label="Tier 2: Write-Only\nrequestWriteOnlyAccessToEvents()\nCan save but not read"];
    "Full access" [shape=box, label="Tier 3: Full Access\nrequestFullAccessToEvents()\nor requestFullAccessToReminders()"];

    "What does your app need?" -> "Add single events to Calendar?" [label="events"];
    "What does your app need?" -> "Full access" [label="reminders\n(always full)"];
    "Add single events to Calendar?" -> "No access + EventKitUI" [label="yes, one at a time"];
    "Add single events to Calendar?" -> "Show custom create/edit UI?" [label="no, batch or silent"];
    "Show custom create/edit UI?" -> "Write-only access" [label="yes, or batch save"];
    "Show custom create/edit UI?" -> "Read existing events/calendars?" [label="no"];
    "Read existing events/calendars?" -> "Full access" [label="yes"];
    "Read existing events/calendars?" -> "Write-only access" [label="no"];
    "Add single events to Calendar?" -> "No access + Siri Suggestions" [label="reservation-style\n(restaurant, flight, hotel)"];
}
```

**Key rule**: Reminders ALWAYS require full access. There is no write-only tier for reminders.

---

## The Three Access Tiers

### Tier 1: No Access (Preferred)

Present `EKEventEditViewController` — it runs out-of-process on iOS 17+ and requires zero permissions.

```swift
let store = EKEventStore()
let event = EKEvent(eventStore: store)
event.title = "Team Standup"
event.startDate = startDate
event.endDate = Calendar.current.date(byAdding: .hour, value: 1, to: startDate) ?? startDate
event.timeZone = TimeZone(identifier: "America/Los_Angeles")
event.location = "Conference Room A"

let editVC = EKEventEditViewController()
editVC.event = event
editVC.eventStore = store
editVC.editViewDelegate = self
present(editVC, animated: true)
```

**Why this is best**: No permission prompt. No denial risk. System handles Calendar selection and save. Works on iOS 4+.

For reservations (restaurant, flight, hotel, event tickets), use **Siri Event Suggestions** instead — events appear in Calendar inbox without any permission. See the eventkit-ref skill for the INReservation donation pattern.

### Tier 2: Write-Only Access (iOS 17+)

Use only when you need: custom editing UI, batch saves, or silent event creation.

```swift
let store = EKEventStore()
guard try await store.requestWriteOnlyAccessToEvents() else {
    // User denied — handle gracefully
    return
}
let event = EKEvent(eventStore: store)
event.calendar = store.defaultCalendarForNewEvents  // REQUIRED for write-only
event.title = "Recurring Standup"
event.startDate = startDate
event.endDate = endDate
try store.save(event, span: .thisEvent)
```

**Write-only constraints**:
- Returns a single virtual calendar, not the user's real calendars
- Event queries return empty results
- System chooses destination calendar for created events
- Cannot read events back, even ones your app created

**Info.plist required**: `NSCalendarsWriteOnlyAccessUsageDescription`

### Tier 3: Full Access

Use only when your app's core feature requires reading, modifying, or deleting existing events.

```swift
let store = EKEventStore()
guard try await store.requestFullAccessToEvents() else { return }

// Now you can fetch events
let interval = Calendar.current.dateInterval(of: .month, for: Date())!
let predicate = store.predicateForEvents(withStart: interval.start, end: interval.end, calendars: nil)
let events = store.events(matching: predicate)
    .sorted { $0.compareStartDate(with: $1) == .orderedAscending }
```

**Info.plist required**: `NSCalendarsFullAccessUsageDescription`

For reminders:
```swift
guard try await store.requestFullAccessToReminders() else { return }
```

**Info.plist required**: `NSRemindersFullAccessUsageDescription`

---

## Anti-Patterns

| Pattern | Time Cost | Why It's Wrong | Fix |
|---------|-----------|----------------|-----|
| Requesting full access for "add to calendar" | 1-2 sprint days recovering denied users | Full access prompts are denied 30%+ of the time — users distrust reading ALL calendar data | Use EventKitUI or write-only |
| Missing Info.plist key on iOS 17+ | 1-2 hours debugging | Automatic silent denial, no crash, no error, no prompt | Add the correct usage description key |
| Missing Info.plist key on iOS 16 and below | Immediate crash | App crashes on permission request | Add `NSCalendarsUsageDescription` |
| Calling deprecated `requestAccess(to:)` on iOS 17 | Throws error | The old API throws, does not prompt | Use `requestFullAccessToEvents()` or `requestWriteOnlyAccessToEvents()` |
| Creating multiple EKEventStore instances | Stale data bugs | Objects from one store cannot be used with another | Create one store, reuse it |
| Using `Date` math instead of `DateComponents` for durations | DST bugs | Adding 3600 seconds doesn't always equal 1 hour | Use `Calendar.current.date(byAdding:)` |
| Not sorting `events(matching:)` results | Wrong display order | Results are NOT chronologically ordered | Sort with `compareStartDate(with:)` |
| Setting `dueDateComponents` with `Date` instead of `DateComponents` | Silent failure | Reminders use `DateComponents`, not `Date` | Convert via `Calendar.current.dateComponents(...)` |
| Not registering for `EKEventStoreChanged` notification | Stale UI | External Calendar changes are invisible | Register and refetch on notification |
| Ignoring `EKSpan` on recurring events | Modifying all occurrences | `.thisEvent` vs `.futureEvents` controls scope | Always choose explicitly |

---

## Reminder Patterns

Reminders ALWAYS require `requestFullAccessToReminders()`.

### Creating a Reminder

```swift
let reminder = EKReminder(eventStore: store)
reminder.title = "Review PR"
reminder.calendar = store.defaultCalendarForNewReminders()  // Required

// Due dates use DateComponents, NOT Date
if let dueDate = dueDate {
    reminder.dueDateComponents = Calendar.current.dateComponents(
        [.year, .month, .day, .hour, .minute], from: dueDate
    )
}

reminder.priority = EKReminderPriority.medium.rawValue
try store.save(reminder, commit: true)
```

### Fetching Reminders (Async)

Unlike events, reminder fetches are asynchronous:

```swift
let predicate = store.predicateForReminders(in: nil)  // nil = all calendars
let reminders = try await withCheckedThrowingContinuation { continuation in
    store.fetchReminders(matching: predicate) { reminders in
        if let reminders {
            continuation.resume(returning: reminders)
        } else {
            continuation.resume(throwing: TodayError.failedReadingReminders)
        }
    }
}
```

### Creating Reminder Lists

Reminder lists are `EKCalendar` objects filtered by entity type:

```swift
let newList = EKCalendar(for: .reminder, eventStore: store)
newList.title = "Sprint Tasks"

// Source selection matters — prefer .local or .calDAV
guard let source = store.sources.first(where: {
    $0.sourceType == .local || $0.sourceType == .calDAV
}) ?? store.defaultCalendarForNewReminders()?.source else {
    throw EventKitError.noValidSource
}

newList.source = source
try store.saveCalendar(newList, commit: true)
```

---

## Store Lifecycle

### Singleton Pattern

Create one `EKEventStore` and reuse it. Objects from one store instance cannot be used with another.

### Change Notifications

```swift
NotificationCenter.default.addObserver(
    self, selector: #selector(storeChanged),
    name: .EKEventStoreChanged, object: store
)

@objc func storeChanged(_ notification: Notification) {
    // Refetch your current date range
    // Individual objects: call refresh() — if false, refetch
}
```

### Batch Operations

```swift
// Pass commit: false for batch, then commit once
try store.save(event1, span: .thisEvent, commit: false)
try store.save(event2, span: .thisEvent, commit: false)
try store.commit()  // Atomic save
// On failure: store.reset() to rollback
```

---

## Migration from Pre-iOS 17

| Before iOS 17 | iOS 17+ Replacement |
|----------------|---------------------|
| `requestAccess(to: .event)` | `requestFullAccessToEvents()` or `requestWriteOnlyAccessToEvents()` |
| `requestAccess(to: .reminder)` | `requestFullAccessToReminders()` |
| `NSCalendarsUsageDescription` | `NSCalendarsFullAccessUsageDescription` or `NSCalendarsWriteOnlyAccessUsageDescription` |
| `NSRemindersUsageDescription` | `NSRemindersFullAccessUsageDescription` |
| `authorizationStatus == .authorized` | Check for `.fullAccess` or `.writeOnly` |

**Runtime compatibility**:
```swift
if #available(iOS 17.0, *) {
    granted = try await store.requestFullAccessToEvents()
} else {
    granted = try await store.requestAccess(to: .event)
}
```

**Keep old Info.plist keys** alongside new ones to support iOS 16 and below.

**Gotcha**: Apps built with older Xcode SDKs map both `.writeOnly` and `.fullAccess` to `.authorized`. This means an app linked against an old SDK may fail to fetch events even after users granted full access — because the app sees `.authorized` but the system gave `.writeOnly`.

---

## EventKitUI Decision Guide

| Controller | Purpose | Permission Required |
|------------|---------|---------------------|
| `EKEventEditViewController` | Create/edit events | None (iOS 17+ out-of-process) |
| `EKEventViewController` | Display event details | Full access |
| `EKCalendarChooser` | Calendar selection | Write-only or full |

**Gotcha**: `EKEventEditViewController` inherits from `UINavigationController`, not `UIViewController`. Do NOT embed it inside another navigation controller.

**Gotcha**: `EKEventViewController` inherits from `UIViewController` and CAN be pushed onto a navigation stack.

**Gotcha**: Under write-only access, `EKCalendarChooser` ignores `displayStyle` and always shows writable calendars only.

---

## Pressure Scenarios

### Scenario 1: "Just request full access, we might need it later"

**Pressure**: Product manager asks for full access "just in case."

**Why resist**: Full access prompts are denied 30%+ of the time. Write-only or EventKitUI gets you event creation with near-zero denials. You can always upgrade later if a reading feature is added.

**Response**: "Full access shows a scary prompt about reading ALL calendar data. For adding events, EventKitUI needs no prompt at all. Let's start there and upgrade if we ship a feature that reads events."

### Scenario 2: "The deprecated API still works, we'll migrate later"

**Pressure**: Deadline pressure to skip migration from `requestAccess(to:)`.

**Why resist**: On iOS 17, calling `requestAccess(to: .event)` throws an error — no prompt, no access, broken feature. Users on iOS 17+ get a silent failure.

**Response**: "The deprecated API throws on iOS 17. It's not 'deprecated but works' — it's broken. The fix is a 3-line `#available` check."

### Scenario 3: "Just create a new EKEventStore for each screen"

**Pressure**: Different view controllers each create their own store for isolation.

**Why resist**: Objects from one store cannot be used with another. Events fetched from store A cannot be saved by store B. Change notifications only fire on the store that's registered.

**Response**: "EventKit requires a single shared store. Objects are bound to the store that created them. Create one and inject it."

---

## Error Handling

Key `EKErrorDomain` codes to handle:

| Code | Meaning | Fix |
|------|---------|-----|
| `eventStoreNotAuthorized` | No permission | Check and request access first |
| `noCalendar` | Calendar not set on event | Set `event.calendar` before save |
| `noStartDate` / `noEndDate` | Missing dates | Set both before save |
| `datesInverted` | End before start | Validate date order |
| `calendarReadOnly` / `calendarIsImmutable` | Can't write to this calendar | Use `allowsContentModifications` check |
| `objectBelongsToDifferentStore` | Cross-store usage | Use single store instance |
| `recurringReminderRequiresDueDate` | Recurring reminder missing due date | Set `dueDateComponents` |

---

## Resources

**WWDC**: 2023-10052, 2020-10197

**Docs**: /eventkit, /eventkitui, /technotes/tn3152, /technotes/tn3153

**Skills**: eventkit-ref, contacts, privacy-ux, extensions-widgets
