Skip to content

Calendar Integration

How LucidPal translates natural language into EventKit operations.

End-to-End Flow

User: "Add dentist Friday at 10am, remind me 30 min before"

LLM generates CALENDAR_ACTION block:
[CALENDAR_ACTION:{"action":"create","title":"Dentist",
  "start":"2026-03-20T10:00:00","end":"2026-03-20T11:00:00",
  "reminderMinutes":30}]

ChatViewModel.executeCalendarActions() detects block

CalendarActionController.execute(json:) → CalendarActionResult

CalendarService.createEvent(...) → EventKit EKEventStore

CalendarEventPreview shown as card in chat

User taps "Confirm" → event created

Calendar Context Format

Before generating action blocks, the LLM receives the user's upcoming events as plain text. Each event line includes a stable identifier suffix so the LLM can target events precisely:

Team Meeting — Mon Mar 20 14:00–15:00 {id:EKEvent-<identifier>}
Dentist       — Fri Mar 22 10:00–11:00 {id:EKEvent-<identifier>}

The {id:...} value is the raw EKEvent.eventIdentifier. The LLM should pass it as eventIdentifier in reschedule actions and as disambiguation context in delete/update when multiple similarly named events exist.

Action Block Format

The LLM is instructed (via system prompt) to output structured JSON wrapped in a recognizable tag:

[CALENDAR_ACTION:{...JSON...}]

Supported Actions

create
json
{
  "action": "create",
  "title": "Team Meeting",
  "start": "2026-03-20T14:00:00",
  "end": "2026-03-20T15:00:00",
  "location": "Zoom",
  "notes": "Weekly sync",
  "reminderMinutes": 15,
  "isAllDay": false,
  "recurrence": "weekly",
  "recurrenceEnd": "2026-06-01T00:00:00"
}

reminderMinutes must be zero or a positive integer. Negative values are rejected with an error.

update
json
{
  "action": "update",
  "search": "Team Meeting",
  "title": "Weekly Review",
  "start": "2026-03-20T15:00:00"
}

Only include fields you want to change. search must match the exact event title. For update and delete, the LLM searches within a 60-day window. The search field is required for update and delete — it disambiguates by title.

reminderMinutes must be zero or a positive integer. Negative values are rejected with an error before the event is written.

reschedule
json
{
  "action": "reschedule",
  "eventIdentifier": "<EKEvent.eventIdentifier>",
  "start": "2026-03-20T15:00:00",
  "end": "2026-03-20T16:00:00"
}

Moves an event to a new start/end time using its {id:EKEvent-<identifier>} from the calendar context. The identifier is stable across event renames — reschedule uses this rather than a title search.

Conflict detection runs against the new window (the event itself is excluded from its own conflict check). Requires all three fields: eventIdentifier, start, and end.

delete
json
{ "action": "delete", "search": "Dentist" }

Deletes the first event matching search within a 60-day window. Or delete a date range:

json
{
  "action": "delete",
  "start": "2026-03-23T00:00:00",
  "end": "2026-03-23T23:59:59"
}

Title-based delete requires search. Date-range delete requires both start and end.

list
json
{
  "action": "list",
  "start": "2026-03-17T00:00:00",
  "end": "2026-03-21T23:59:59"
}

Returns a single CalendarEventListCard containing grouped CalendarEventPreview items (state: .listed) — tap any row to open it in Calendar.

query (free slots)
json
{
  "action": "query",
  "start": "2026-03-17T00:00:00",
  "end": "2026-03-21T23:59:59",
  "durationMinutes": 60
}

Returns available time windows via CalendarFreeSlotEngine.

Confirmation Flow

All destructive or mutating actions go through a two-step confirmation UI:

LLM outputs action block

CalendarEventPreview created with state = .pendingDeletion / .pendingUpdate / .pendingReschedule

Card shown with [Keep] / [Delete] or [Cancel] / [Apply] buttons

User taps confirm → ChatViewModel.confirmDeletion() / confirmUpdate() / confirmReschedule()

CalendarService executes → preview.state = .deleted / .updated / .rescheduled

Preview States

StateMeaning
.createdEvent successfully created — tap to open in Calendar
.pendingDeletionAwaiting user confirmation to delete
.deletedDeleted — shows strikethrough + Undo button
.deletionCancelledUser kept the event
.pendingUpdateAwaiting user confirmation to apply changes
.updatedUpdated in place
.updateCancelledUser dismissed the update
.restoredEvent recreated after undo
.pendingRescheduleAwaiting user confirmation to move event
.rescheduledStart/end times changed
.rescheduleCancelledUser dismissed the reschedule
.listedRead-only list result — shown in CalendarEventListCard, no confirm buttons

Anti-Corruption Layer

CalendarService never exposes EKEvent or EKCalendar to the ViewModel layer. All EventKit types are mapped to domain structs at the service boundary:

swift
// Domain type — no EventKit import needed above service layer
struct CalendarInfo: Identifiable, Hashable, Sendable {
    let id: String      // EKCalendar.calendarIdentifier
    let title: String
}

Conflict Detection

When creating, updating, or rescheduling an event, CalendarService checks for overlapping events in the same time window:

swift
func findConflicts(start: Date, end: Date, excludingIdentifier: String? = nil) -> [CalendarEventInfo]
func findEvent(identifier: String) -> CalendarEventInfo?

findConflicts accepts an optional excludingIdentifier so that rescheduling an event does not flag the event itself as a conflict. findEvent looks up a single event by its EventKit identifier and returns nil if the event no longer exists.

If conflicts exist, CalendarEventPreview.hasConflict = true and an orange badge is shown on the card. Tapping the badge opens a ConflictDetailSheet with:

  • List of conflicting events (title, time range, calendar name, "Recurring" badge if applicable)
  • Three actions: Keep Anyway, Find Free Slot (searches next 7 days for gaps fitting the event's duration), Cancel Event

CalendarFreeSlotEngine

A pure static algorithm with zero dependencies — fully testable without EventKit:

swift
enum CalendarFreeSlotEngine {
    static func findSlots(
        busyWindows: [(start: Date, end: Date)],
        rangeStart: Date,
        rangeEnd: Date,
        duration: TimeInterval
    ) -> [CalendarFreeSlot]
}

CalendarActionController fetches busy windows from CalendarService and passes them to the engine. The engine returns available slots that fit the requested duration.

For a deep-dive into the sweep algorithm, working hours defaults, all-day event handling, and edge cases, see CalendarFreeSlotEngine.

Undo

After any calendar write (create, delete, update, reschedule), an Undo button appears on the result card. Tap it during the same session to reverse the action immediately.

For deletions specifically, you can also say "Hey Siri, undo my last LucidPal action" at any time — even after closing the app — to restore the most recently deleted event. The SiriContextStore records every calendar action (type, eventIdentifier, title, start/end, calendarIdentifier) so undo works for both in-app and Siri-triggered actions.

Bulk Deletion

When two or more events are proposed for deletion simultaneously (date-range delete with multiple matches), a BulkDeletionBar appears below the event cards with Delete All and Keep All buttons. Individual cards still have their own confirm/cancel buttons.

Permissions

CalendarService requests fullAccess EventKit authorization. If access is denied, the LLM is informed via the system prompt section and will tell the user to enable calendar access in Settings. Read and write operations both require the same authorization.

Internal — not for distribution