Epic: Calendar Connector
A first-party Tier 3 connector letting the model create, update, delete, and query the user's own on-device calendar.
Overview
The first connector that reaches an on-device OS capability rather than the network — a Tier 3 connector per the Connector Framework's own original three-tier design (research 0001), not Tier 1 like Search and Sovereign Tasks. Blocked on epic 2's task 2.6 (Tier 3 scaffolding), which this epic is the first real consumer of on both mobile and desktop.
Full findings, options, and the reasoning behind this epic's scope live in research 0005 (mobile) and research 0011 (desktop — why macOS only) — this file states the decided scope as tasks, those docs keep the why. Originally scoped mobile-only; the user asked for desktop too, which is what task 10.2 and research 0011 add.
Tasks
✅ 10.1 — Calendar connector (mobile)
Goal: Let the model create, update, delete, and query events on the user's own device calendar, via expo-calendar.
Deliverables:
- A Tier 3 connector manifest (per task 2.6's schema) declaring four tools:
calendar.create_event,calendar.update_event,calendar.delete_event,calendar.query_events. - Reminders are modeled as an alert offset on a created event, not a separate API surface — the one interpretation of "reminders" that behaves identically on iOS and Android (research 0005's findings: iOS EventKit has a genuinely separate Reminders store; Android has no equivalent at all).
- MVP scope is deliberately narrow: single, non-recurring, timezone-naive events. Recurrence, timezone handling, and all-day events are explicitly out of scope for this task.
- New events are written to a fixed default/primary calendar for MVP — see Open Questions below for the eventual calendar-picker flow.
Dependencies: Task 2.6 (Tier 3 scaffolding).
Review checklist:
- Creating an event via chat results in a real event appearing in the device's own Calendar app, on both platforms.
- Querying "what's on my calendar" returns events actually present on the device, not a stale or cached view.
- Deleting or updating an event via chat is reflected in the Calendar app immediately.
- Revoking this connector's permission does not affect any other connector's permission (Search, Sovereign Tasks) or vice versa — the same bar task 2.2 already set for Tier 1 connectors, now proven for Tier 3.
Open questions from research 0005, resolved during implementation:
- Which calendar new events are written to — resolved as the primary/ default calendar; no calendar-picker setup step was added, kept as a plausible fast-follow rather than in-scope here.
expo-calendar@57.0.1is compatible with the installed Expo SDK 57/RN 0.86 toolchain — confirmed by a realpnpm installand reading its actualpackage.json/type declarations, not assumed. One real surprise: SDK 57'sexpo-calendarships a new, sync-first default API (under'expo-calendar'itself) that has no event CRUD at all — the functions this connector needs (createEventAsync,getEventsAsync,requestCalendarPermissionsAsync, etc.) only exist under the'expo-calendar/legacy'subpath, confirmed by reading the package's ownexportsmap, not guessed from an older SDK's shape.- iOS's full-access vs. write-only permission tiers: resolved by not setting
writeOnlyAccess: truein theexpo-calendarconfig plugin (app.json) — its default path setsNSCalendarsFullAccessUsageDescription, givingquery_eventsthe access tier it needs, confirmed by reading the plugin's own source rather than assumed from the option name.
Decided (not in research 0005):
- Four separate manifests/connector ids, not one manifest with four tools. The Tier 3 schema gives every manifest exactly one
tooland onehandler.capability— there is no shape for "one connector, several tools." This is a real fit with the app's "no blanket toggle, per-connector consent" philosophy (a user can allowquery_eventswithout allowingdelete_event), not a workaround. - One real OS permission prompt for all four, not four. All four manifests share one underlying OS permission domain —
permissions/ calendarAccess.ts'sensureCalendarAccess()checks current status first and only prompts if undetermined, called fromConnectorsScreen.tsx's grant handler before the app's owngrant(), never after (an OS denial must never let the app record "granted" for a connector EventKit/Calendar will actually refuse to run).
Verified:
pnpm --filter mobile typecheck/lint/test(293 tests, up from 268 —calendar/manifest.test.ts,calendar/handlers.test.ts(mockingexpo-calendar/legacy),permissions/calendarAccess.test.ts, and newConnectorsScreen.test.tsxcases for the permission-then-grant flow and OS-denial message).- Real iOS Simulator build and launch: a real
pod install(linked the newExpoCalendar (57.0.1)native module for the first time) and a realxcodebuildagainst the actual Xcode project, then a real tap through Settings → Connectors on a booted simulator, confirming the four Calendar rows are genuinely rendered from the real manifests (not a mock) and that tapping one calls the realensureCalendarAccess()→ realexpo-calendar/legacyrequestCalendarPermissionsAsync()→ a real OS response, handled correctly (nogrant()call, the exact denial message shown, state remainsnot-askedrather than falselygranted).
Honest gap:
- A real interactive "Allow" tap could not be captured. This automated build+launch context (via this environment's iOS Simulator build tool) never surfaced a visible system permission dialog to tap — the OS resolved straight to denied.
xcrun simctl privacy grant calendar <bundle-id>was also tried directly against the booted simulator and did not flip whatgetCalendarPermissionsAsync()reads back at runtime. The denial path itself is proven correct end to end (real API call, real response, correctly handled) — only the granted path's real device behavior remains unconfirmed, the same class of gap as task 10.2's desktop EventKit dialog. A real device or an interactive (non-headless) Simulator.app session, with a human's one-time tap, would close it.
Related Docs
- research 0001
- research 0005
- research 0011 (desktop, task 10.2)
- Connector Framework (task 2.6, the Tier 3 scaffolding this epic depends on)
Cross-references
- Shares its Tier 3 dependency (task 2.6) with the Device connector (epic 11).
- Mirrors the Sovereign Tasks connector's (epic 4) "direct integration, not delegation" pattern — this connector writes to the calendar itself, it does not hand off to another app's UI.
✅ 10.2 — Desktop calendar connector (macOS only)
Goal: The same four tools, on desktop, via real Apple EventKit bindings — macOS is the only desktop OS this pass supports; see research 0011 for why Windows (package-identity requirement this app's unpackaged distribution doesn't have) and Linux (no single calendar API) are out of scope for now.
Deliverables:
apps/desktop/src-tauri/src/connectors/calendar/{mod,manifest,handlers,access}.rs,#[cfg(target_os = "macos")]-gated: four manifests mirroring mobile's (same ids/tools,platforms: ["desktop"]), synchronous CRUD/query handlers viaobjc2-event-kit'sEKEventStore, and the one completion-handler → async bridge this connector needs (requestFullAccessToEventsWithCompletion, exposed as its ownrequest_calendar_accessTauri command).known_connector_manifests()inlib.rsincludes the four manifests viaconnectors::calendar::calendar_manifests(), which itself returns an emptyVecoff macOS — nocfg!needed at the call site, and Windows/ Linux builds never advertise a connector they can't run.- Same permission-ordering rule as mobile:
ConnectorsScreen.tsxcallsrequest_calendar_accessand only callsset_connector_granted(id, true)on a realtrueresult.
Dependencies: Task 2.6 (Tier 3 scaffolding, desktop side); research 0011.
Review checklist:
- Same four review points as task 10.1, on macOS: create/update/delete/ query all reflect in the real macOS Calendar app; revoking one calendar connector doesn't touch another connector's grant.
- A Windows or Linux build's Connectors screen never shows a Calendar row at all (not a broken one).
Decided:
- Same capability-doubles-as-permission-scope shape mobile ended up needing, for the same reason: the validator's cross-field rule (
validate.rs'stier3CrossFieldIssues) requireshandler.capabilityto be a member ofpermissions.device.capabilities— a coarser shared scope across the three write manifests isn't expressible without duplicating both strings per manifest, which would just make the per-connector scope text shown to the user redundant. - A fresh
EKEventStoreper handler call, not a held singleton. Apple's own docs call a long-lived instance a performance recommendation, not a correctness requirement, and aRetained<T>Objective-C object isn't safely shareable across this app's multi-threaded Tauri command dispatch without its own synchronization — a fresh instance per call sidesteps that entirely. - No date/time crate dependency for parsing the
YYYY-MM-DDTHH:MM:SSZstrings the manifests declare —parse_iso8601_utc/days_from_civilinhandlers.rsreuse the same Howard Hinnant civil-date algorithmpermissions/grants.rs's owniso_now/civil_from_daysalready established as this app's precedent for one-format date handling without a new dependency.
Honest gap:
- The real macOS system Calendar-access dialog cannot be clicked by any tool available in this environment — no macOS screen-automation tool exists (unlike the iOS Simulator, which does support tapping through system dialogs).
already_authorized()/request_access()are proven correct against the real, side-effect-freeauthorizationStatusForEntityTypequery and a realcargo build/launch, but the actual "user clicks Allow" round trip needs a human's one-time manual click during a follow-up check — the same class of gapvault_smoke.rs's real-keychain integration test already carries for this codebase.
Verified:
cargo fmt/clippy/test(150 tests, up from 137 — 13 new calendar tests: manifest validation against the real validator, ISO-8601 parse/ format round-trip against a real computed epoch timestamp, required- field refusal for all four handlers, andnative_handler_forcapability resolution).pnpm --filter desktop typecheck/lint/test(53 tests, up from 49).- Real debug binary build (
pnpm tauri build --debug --no-bundle, including a realobjc2-event-kit/block2link) and launch-smoke check: process starts, stays running, no panic.
📋 10.3 — Desktop calendar connector, Windows/Linux (fast-follow)
Goal: Extend task 10.2's desktop calendar connector to Windows and Linux, closing the gap research 0011 documented rather than solved. Explicitly not scoped for this pass — handed off as a real, scoped fast-follow rather than left as a vague "someday." macOS's own connector (task 10.2) is unaffected either way: calendar_manifests() stays #[cfg(target_os = "macos")]-gated today, and whoever picks this up adds sibling #[cfg(target_os = "windows")]/#[cfg(target_os = "linux")] modules alongside it, not a rewrite of it.
Deliverables (per research 0011's findings — this is scope, not new research):
- Windows:
windows::ApplicationModel::Appointments::AppointmentStore(the officialwindowscrate) is the real API, but it requires package identity this app's current unpackaged NSIS/MSI distribution doesn't have. First decision to make: adopt a sparse/external-location package (identity without full MSIX) — check this doesn't conflict with.github/workflows/desktop-release.yml's existing release pipeline before committing to it — or fall back to Outlook COM automation (works unpackaged, but only when Outlook happens to be installed, an assumption this app makes nowhere else today). - Linux: no single calendar API. Real support means picking one backend — GNOME's
evolution-data-serverD-Bus interface is the pragmatic default research 0011 flagged (more standardized than KDE's Akonadi), not a decision already made — and documenting other desktop environments as unsupported, the same honest-gap pattern task 10.2 used for Windows/Linux as a whole. - Same four tools, same manifest ids/capability shape as task 10.2 (one manifest = one tool = one
handler.capability, matchingpermissions.device.capabilitiesper the validator's cross-field rule);known_connector_manifests()needs no change beyond each new platform's owncalendar_manifests()returning real entries instead ofVec::new().
Dependencies: Task 10.2 (the manifest/handler shape to mirror); research 0011 (the platform findings — read this before starting, it's not just a stub).
Review checklist:
- Windows: create/update/delete/query all reflect in the real Windows Calendar app (or Outlook, if that's the fallback taken), from a real installed (not just
cargo run) build — package-identity issues often only surface post-install, not in a dev build. - Linux (GNOME): same four operations reflect in GNOME Calendar/Evolution. A non-GNOME Linux desktop's Connectors screen shows no Calendar row (same "absent, not broken" bar task 10.2 set for pre-this-task Windows/ Linux) rather than a row that fails at dispatch time.
Not decided — real forks for whoever picks this up:
- Windows: sparse package vs. Outlook COM (see Deliverables above) — this is a real architectural fork with distribution-pipeline consequences, not a detail to default silently.
- Linux: GNOME/EDS only, or also attempt KDE/Akonadi? EDS-only was research 0011's pragmatic-default flag, not its decision.