Apple Calendar (iCloud) Two-Way Sync via CalDAV
⚠️ Cookbook example — not live code. Every code block below is an example snippet, not part of the llamapress.ai codebase, and not running on this server. This is a reference recipe for a Leo instance (an AI coding agent) to implement in its own app — read it to understand the pattern, then recreate it there. (A copy-ready install package lives in
app/views/cookbook/apple-calendar-caldav-sync/— see itsINSTALL.md.)
Apple has no public REST API for iCloud Calendar — but it runs a standards-compliant
CalDAV server at caldav.icloud.com, the same one Thunderbird and every third-party
calendar app use. That means a Rails app can read AND write a customer's shared Apple
calendar with nothing but Net::HTTP and Nokogiri (both ship with Rails — important on
Leo boxes, where you cannot add gems). Auth is the customer's Apple ID plus an
app-specific password they mint at account.apple.com in about a minute.
The result: a /calendar page with a month grid + upcoming list fed live from iCloud,
and an "Add event" form whose events land on the shared calendar — and on everyone's
iPhones — seconds later. Proven end-to-end (read + create + delete round-trip) against a
real iCloud account.
When to use: a client's team lives on a shared Apple calendar and wants those events inside the app, or wants the app to put events onto it. Works for any calendar the Apple ID owns or can edit. When not to: the client is on Google Calendar (use the Google integration — real API, webhooks). Or you need push updates — CalDAV here is poll-on-page-load; there are no webhooks.
The 80/20 in one breath
- Copy
AppleCalendarClient(the install package has it verbatim) — discovery, read, write, ICS parse/build in one ~250-line service, zero gems. - One singleton table
apple_calendar_settings(apple_id, app_password, calendar_url, calendar_name) + model with.current/connected?. - One controller:
show(month grid),settings/connect/select_calendar(three-step connect flow),create_event,disconnect. - Three views: month grid + add-event modal, a settings page with the app-specific-password walkthrough, and a calendar picker.
- Six routes under
/calendar, one nav link. - The customer mints an app-specific password and connects on
/calendar/settings.
Layer 1 — How the CalDAV conversation works (the part worth understanding)
Four HTTP verbs against https://caldav.icloud.com/, all Basic-auth'd with
apple_id:app_specific_password:
# app/services/apple_calendar_client.rb (excerpts — full file in the install package)
# 1) WHO AM I — PROPFIND / for the principal URL
PRINCIPAL_XML = <<~XML.freeze
<d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>
XML
# 2) WHERE ARE MY CALENDARS — PROPFIND the principal for calendar-home-set,
# then PROPFIND Depth:1 on the home to list calendars. Keep only real
# VEVENT-capable calendars (skip inbox/outbox/reminders):
next unless node.at_xpath(".//*[local-name()='resourcetype']/*[local-name()='calendar']")
comps = node.xpath(".//*[local-name()='supported-calendar-component-set']/*[local-name()='comp']").map { |c| c["name"] }
next if comps.any? && !comps.include?("VEVENT")
# 3) READ — REPORT calendar-query with a time window; <c:expand> makes APPLE
# expand recurring events into individual occurrences (no RRULE engine needed):
def query_xml(from_utc, to_utc, expand:)
data = expand ? %(<c:calendar-data><c:expand start="#{from_utc}" end="#{to_utc}"/></c:calendar-data>) : "<c:calendar-data/>"
<<~XML
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:getetag/>#{data}</d:prop>
<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT">
<c:time-range start="#{from_utc}" end="#{to_utc}"/>
</c:comp-filter></c:comp-filter></c:filter>
</c:calendar-query>
XML
end
# 4) WRITE — PUT a minimal VCALENDAR to <calendar_url><uuid>.ics
# with If-None-Match: * (create-only, never overwrite).
# DELETE on that same URL removes the event (204).
Two infrastructure details the request core must handle (the package file does):
# iCloud 3xx-redirects you to a partition host (p112-caldav.icloud.com) —
# follow redirects AND resolve returned hrefs against the FINAL uri, not BASE_URL.
# And because every request carries the credentials, hard-refuse foreign hosts:
ALLOWED_HOST = /(\A|\.)icloud\.com\z/
raise Error, "Refusing to send Apple credentials to #{uri.host}" unless uri.host.to_s.match?(ALLOWED_HOST)
Layer 2 — Model & migration
# app/models/apple_calendar_setting.rb — singleton row
class AppleCalendarSetting < ApplicationRecord
# Password is a PLAIN column ON PURPOSE: Rails `encrypts` derives its key from
# SECRET_KEY_BASE, which rotates on Leo-instance sleep/wake — an encrypted
# password would silently brick on the next wake. App-specific passwords are
# revocable at account.apple.com, so plain is the safer fleet trade.
validates :calendar_url, format: { with: %r{\Ahttps://[\w.-]*icloud\.com[/:]}, allow_blank: true }
def self.current = first || create!
def credentialed? = apple_id.present? && app_password.present?
def connected? = credentialed? && calendar_url.present?
def client = AppleCalendarClient.new(apple_id: apple_id, app_password: app_password)
end
# db/migrate/<timestamp>_create_apple_calendar_settings.rb
create_table :apple_calendar_settings do |t|
t.string :apple_id, :app_password, :calendar_url, :calendar_name
t.datetime :connected_at
t.timestamps
end
Layer 3 — Controller flow
# app/controllers/calendar_controller.rb (shape — full file in the package)
# show: month grid; fetch events for the visible grid window, live, per load
# settings: the walkthrough + credential form
# connect (POST): verify creds by running discovery; on success SAVE creds and
# render the calendar picker ← this render is why the form is turbo:false
# select_calendar: save the chosen {name, url}; done
# create_event: build times from the form (all-day or timed), PUT to iCloud, redirect
# disconnect: nil out the row
def show
@month = parse_month
return unless @settings.connected?
grid_from = @month.beginning_of_month.beginning_of_week(:sunday)
@grid_days = (grid_from..@month.end_of_month.end_of_week(:sunday)).to_a
events = @settings.client.events(@settings.calendar_url,
from: @grid_days.first.in_time_zone,
to: (@grid_days.last + 1).in_time_zone)
@events_by_date = index_by_date(events) # multi-day events on every day they span
@upcoming = events.select { |e| (e.ends_at || e.starts_at) >= Time.zone.now }.first(12)
rescue AppleCalendarClient::AuthError => e
@calendar_error = e.message # amber banner + "Reconnect" link, never a 500
end
Layer 4 — The views
Full files in the package: a 7-column month grid (all-day events as solid pills, timed ones outlined with the start time, "+N more" past three, today badged, sideways-scroll on phones), a "Coming up" list that doubles as the phone view, an add-event modal in plain delegated JS (no Stimulus dependency), and the settings/picker pages. All Tailwind
- Font Awesome FREE icons.
Gotchas (the hard-won stuff)
- Turbo eats the picker page — a user connects and "nothing happens." The
connectaction renders the calendar picker on success, and Turbo silently drops non-redirect responses to form posts. Symptom in production: the loading bar flashes, the page doesn't change, yet the credentials SAVED (discovery ran before the render). The connect form must carrydata: { turbo: false }. This shipped broken once; don't repeat it. - The user's normal Apple password can never work — CalDAV requires an app-specific password (account.apple.com → Sign-In and Security → App-Specific Passwords). Say so in the UI or every first attempt fails.
DTENDis EXCLUSIVE. A one-day all-day event hasDTEND= the NEXT date. Add a day when writing all-day events; subtract one when deciding which grid days an event occupies, or every all-day event paints one day too many.- Let Apple expand recurrences.
<c:expand>in the REPORT returns individual occurrences, so you never write an RRULE engine. Keep a no-expand retry fallback and flag mastersrecurring. - Parse
...Ztimestamps withTime.utcexplicitly —Time.strptimewithout zone info assumes process-local time and shifts every event. - ICS text is folded and escaped: continuation lines start with a space/tab (unfold
before parsing), and
\n\,\;\\need unescaping (and escaping on write). SkipSTATUS:CANCELLEDevents. - Follow redirects to the partition host and resolve DAV
hrefs against the final URI. And refuse to send credentials to any non-icloud.com host — the storedcalendar_urlis user-influencable, and every request carries Basic auth. - Don't
encryptsthe password on a Leo box —SECRET_KEY_BASErotates on sleep/wake and bricks encrypted columns. Plain + revocable beats encrypted + bricked. allow_browser versions: :modernblocks iPhones below iOS 17.2 with a dead "browser not supported" page — and a calendar is a phone page. Override the check in this controller.- Latency: the page fetches iCloud live on every load (~1–2s). Fine for an internal tool; add a cache table if it ever isn't. There are no webhooks in CalDAV-land.
Files this pattern touches
app/services/apple_calendar_client.rb # the CalDAV client (verbatim drop-in)
app/models/apple_calendar_setting.rb # singleton connection row
app/controllers/calendar_controller.rb
app/views/calendar/show.html.erb # month grid + upcoming + add-event modal
app/views/calendar/settings.html.erb # walkthrough + credential form (turbo:false)
app/views/calendar/choose.html.erb # calendar picker
db/migrate/<timestamp>_create_apple_calendar_settings.rb
config/routes.rb # six routes under /calendar
How to adapt to your schema
- The client and model are drop-ins — rebrand only the
PRODIDstring. - Point
CalendarControllerat your app's base controller / layout; keep theallow_browseroverride. - Restyle the views to your app's palette; the grid logic (
@grid_days,@events_by_date) is view-agnostic. - Deleting events from the app:
client.send(:request, :delete, "#{calendar_url}#{uid}.ics")returns 204 — promote it to a publicdelete_event(calendar_url, uid)if you build event management UI. - Multiple calendars: drop the singleton (
.current) pattern for ahas_many-style table keyed by calendar_url; the client doesn't care. - Read-only variant with zero credentials: skip all of this and subscribe to the
calendar's public
webcal://URL instead — fetch and parse the ICS with the sameparse_ics.