Apple Calendar (iCloud) Two-Way Sync via CalDAV | LlamaPress Cookbook
LlamaPress
Cookbook
Integrations Stable

Apple Calendar (iCloud) Two-Way Sync via CalDAV

Show a shared Apple/iCloud calendar inside your app and write events back onto it — no gems, no OAuth app, just Net::HTTP speaking CalDAV with an app-specific password.

Proven on duke-homes.leo.llamapress.ai model · controller · view

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 its INSTALL.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

  1. Copy AppleCalendarClient (the install package has it verbatim) — discovery, read, write, ICS parse/build in one ~250-line service, zero gems.
  2. One singleton table apple_calendar_settings (apple_id, app_password, calendar_url, calendar_name) + model with .current / connected?.
  3. One controller: show (month grid), settings/connect/select_calendar (three-step connect flow), create_event, disconnect.
  4. Three views: month grid + add-event modal, a settings page with the app-specific-password walkthrough, and a calendar picker.
  5. Six routes under /calendar, one nav link.
  6. 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 connect action 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 carry data: { 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.
  • DTEND is EXCLUSIVE. A one-day all-day event has DTEND = 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 masters recurring.
  • Parse ...Z timestamps with Time.utc explicitlyTime.strptime without 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). Skip STATUS:CANCELLED events.
  • 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 stored calendar_url is user-influencable, and every request carries Basic auth.
  • Don't encrypts the password on a Leo boxSECRET_KEY_BASE rotates on sleep/wake and bricks encrypted columns. Plain + revocable beats encrypted + bricked.
  • allow_browser versions: :modern blocks 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

  1. The client and model are drop-ins — rebrand only the PRODID string.
  2. Point CalendarController at your app's base controller / layout; keep the allow_browser override.
  3. Restyle the views to your app's palette; the grid logic (@grid_days, @events_by_date) is view-agnostic.
  4. Deleting events from the app: client.send(:request, :delete, "#{calendar_url}#{uid}.ics") returns 204 — promote it to a public delete_event(calendar_url, uid) if you build event management UI.
  5. Multiple calendars: drop the singleton (.current) pattern for a has_many-style table keyed by calendar_url; the client doesn't care.
  6. 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 same parse_ics.

Related