Skip to main 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
{
"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
{
"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.

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

reschedule
{
"action": "reschedule",
"eventIdentifier": "<event-id>",
"start": "2026-03-20T15:00:00",
"end": "2026-03-20T16:00:00"
}

Moves an event to a new start/end time using its identifier. Conflict detection runs against the new window (the event itself is excluded). Requires all three fields.

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

Or delete a date range:

{
"action": "delete",
"start": "2026-03-23T00:00:00",
"end": "2026-03-23T23:59:59"
}
list
{
"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)
{
"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

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

User taps confirm → ChatViewModel.confirmDeletion() or confirmUpdate()

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
.rescheduledStart/end times changed
.updateCancelledUser dismissed the update
.restoredEvent recreated after undo
.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:

// 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:

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.

CalendarFreeSlotEngine

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

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.