The shape of it
MustDo: To-Do Alarm does one thing: you enter a to-do and a time, and at that time an AlarmKit alarm rings until you stop it. Stopping means "done". If you are not done, you snooze for 10 minutes, 1 hour, or until tomorrow.
There are three moving parts, and it is worth being exact about which of them is ours:
- The app keeps a local copy and an outbox of unsent changes, and turns to-dos into AlarmKit alarms.
- The user's CloudKit private database is the source of truth. We do not operate a server or database that stores to-dos, and there is no account to create.
- MCP lets an AI client read and write those records. A local server on a Mac calls Apple directly. For claude.ai and the Claude iPhone app there is a hosted relay, and that relay is a server of ours. It stores an encrypted CloudKit sign-in token per user and nothing of the to-dos themselves.
Our first plan was a conventional backend: an API, a relational database, email sign-in, our own push. We had started on it when we asked whether any of it was needed. Moving to CloudKit removed sign-in screens, push infrastructure and running costs. What it cost us is at the end.
AlarmKit: snooze until it is done
AlarmKit has a built-in snooze, but it is a single countdown whose length you fix when you schedule the alarm. We needed three choices decided at the moment the alarm rings. So we do not use it. A snooze in MustDo is: stop the ringing alarm, then schedule a new one under a new ID.
The system's full-screen alert has room for the stop button and one more. We map them like this:
| Where | Button | How |
|---|---|---|
| Full-screen alert | Stop = done | stopIntent |
| Full-screen alert | 10 minutes | secondaryButtonBehavior: .custom with a secondaryIntent |
| Live Activity, in-app banner | Done, 10 minutes, 1 hour, tomorrow | Button(intent:) |
The whole design depended on one question: can a LiveActivityIntent call AlarmManager.schedule from perform() while the alarm is ringing? It can. In the simulator, pressing the secondary button on a ringing alarm produced Executing intent for action secondary, Stopping alarm and Scheduling alarm with the new ID in the system log, and the alarm rang again ten minutes later. We ran the whole flow there, lock screen and its four-button Live Activity included, before building the rest of the app.
Record first, then touch the OS
The order inside the intent matters. Abridged from our code:
// 1. Record the decision locally and queue it for iCloud.
cache.markSnoozed(todoID: metadata.todoID, key: metadata.occurrenceKey, until: until, at: now)
outbox.enqueue(OutboxEntry(kind: .snooze(todoID: metadata.todoID, key: metadata.occurrenceKey, until: until, at: now)))
// 2. Stop the ringing alarm.
if let alarmID { try? AlarmManager.shared.stop(id: alarmID) }
// 3. Schedule the next one under a new ID.
let next = AlarmConfigurationBuilder.snoozed(from: metadata, until: until)
_ = try await AlarmManager.shared.schedule(id: next.id, configuration: AlarmConfigurationBuilder.configuration(for: next))
// 4. Send the outbox, best effort.
await IntentHooks.flushOutboxBestEffort()
If you stop first and the write then fails, the next sync sees an alarm that should exist and puts it back. Recording first means a failed schedule is also recoverable: the snooze time is in the local copy, and the next sync schedules it.
The intent carries everything it needs as parameters: title, original due time, time zone, sound. It can stop and re-schedule without reading our local store, which matters when the store is not readable yet, for example before the first unlock after a restart.
Deterministic alarm IDs
AlarmKit lets you choose each alarm's UUID. We derive it from the item's ID and the fire time truncated to the minute, and nothing else:
static func deterministicID(todoID: String, fireDate: Date) -> UUID {
let fireMinute = Int(fireDate.timeIntervalSince1970.rounded(.down)) / 60
let digest = SHA256.hash(data: Data("mustdo|\(todoID)|\(fireMinute)".utf8))
var bytes = Array(digest.prefix(16))
bytes[6] = (bytes[6] & 0x0F) | 0x50 // version
bytes[8] = (bytes[8] & 0x3F) | 0x80 // variant
return bytes.withUnsafeBytes { raw in UUID(uuid: raw.loadUnaligned(as: uuid_t.self)) }
}
If IDs changed on every re-plan, each sync would cancel and re-create alarms, and a pair that straddles the fire time loses the alarm. With stable IDs, sync is a diff against what is already scheduled. A snooze changes the fire minute, so it gets a new ID without special handling. Title and sound are kept out of the ID and tracked in a separate fingerprint that decides when an existing alarm must be rebuilt.
Repeats are expanded by the app
AlarmKit has a relative, weekly schedule. We do not use it, because "tomorrow" on a repeating to-do means "skip today's occurrence only", and we wanted that interaction in one pure function. The app expands each repeat rule itself and pre-registers fixed-date alarms for the next 14 days, at most 3 per repeating to-do so that a daily item cannot use up the alarm budget. "Tomorrow" on a repeat schedules nothing new. It marks today's occurrence as skipped, and tomorrow's is already registered.
CloudKit's private database as the backend
Everything is in one custom zone in the user's private database. A custom zone gives you change tokens for delta fetches. The model is small:
- One record per to-do. The record name is a UUID the app generates, so sending the same create twice still yields one record and we need no idempotency key.
- One
Occurrencerecord per repeating to-do per day that has been touched, named<id>|<YYYY-MM-DD>, holding done, skipped or snoozed state for that day only. - One
Accountrecord with locale, time zone and the default alarm time, so the MCP side can compute "today" and "tomorrow at the default time" the same way the phone does.
Sync runs in a fixed order: push, pull, merge, plan, reconcile. Push goes first on purpose. If you pull first, the pre-snooze state comes down and the planner starts re-registering the alarm you have just snoozed.
- Push saves with
.ifServerRecordUnchanged. OnserverRecordChangedwe take the server's copy from the error, apply our fields to it only if our change is the newer one, and save once more. Per-day state is last writer wins on achangedAtfield. - Pull calls
recordZoneChangeswith the archived server change token. The first run fetches everything. - Merge: content from the server wins, per-day state goes to the later
changedAt, and anything still in the outbox keeps the device's value. Two actions on the same occurrence collapse to the later one in the outbox, so "10 minutes" then "1 hour" cannot arrive in the wrong order. - Plan and reconcile run from the local copy, so alarms are scheduled when the device is offline or not signed in to iCloud.
Other devices and the MCP server reach the phone through a CKDatabaseSubscription with shouldSendContentAvailable = true. A write from anywhere becomes a silent push, and the app runs the same sync in the background. We send no pushes ourselves.
Deletes are soft: the app writes a deletedAt so other writers learn that the item is gone. Once a day, after a successful pull, the app purges from iCloud: completed one-off items 30 days after completion, soft-deleted items after 14. The MCP server never hard-deletes.
MCP, part one: a local server that calls Apple directly
The local MCP server is a small Node program speaking stdio. It uses CloudKit Web Services, Apple's REST interface, against the same container and zone as the app. Two tokens are involved, and they do different jobs:
- The API token identifies the container and fixes the URL Apple redirects to after sign-in. Apple designs it for client-side use. By itself it opens no private database.
- The web auth token is the user's own session, issued by Apple's sign-in page. This is the credential that opens the user's private database. Locally it is stored in a file under the user's home directory with mode 0600.
To find the sign-in page, call users/caller without a web auth token. CloudKit answers with HTTP 421 and a redirectURL. Open that in the browser, listen on localhost, and Apple redirects back with the session in the query string. The same 421 later tells you the session has expired.
What bit us:
- API tokens are per environment. We ended up with separate tokens for Development and Production, for the local server and for the relay. The saved session is per environment too, so switching means signing in again.
- A Production API token cannot use a localhost sign-in callback. The Dashboard offers https or a custom scheme there. We added a small https endpoint on our site that answers with a 302 to
http://localhoston the callback port, with the host hard-coded so it cannot become an open redirect, and that neither stores nor logs the query. So the local server's data requests go from the Mac to Apple, but the sign-in redirect does pass through that one bounce. users/callerexists on the public database only. Sent to the private database it returnsBAD_REQUEST.
MCP, part two: the relay for claude.ai and the iPhone app
claude.ai connectors and the Claude iPhone app can only reach an HTTPS MCP server, so a hosted piece is unavoidable there. The relay exposes the same seven tools, built from the same source as the local server, over stateless Streamable HTTP, behind an OAuth 2.1 authorization server (PKCE S256 required, dynamic client registration with a redirect allowlist, refresh token rotation with reuse detection).
The relay does have a data store of its own: one DynamoDB table. What goes into it and what does not:
| Stored | Not stored, not logged |
|---|---|
| The user's CloudKit web auth token, sealed with AES-256-GCM under a per-user data key from AWS KMS, with the user's hashed ID as encryption context | To-do content: titles, notes, times |
| OAuth access and refresh tokens, as peppered SHA-256 hashes only | Apple ID email or password (sign-in happens on Apple's page) |
| A user ID that is an HMAC of the CloudKit user record name | The raw user record name, IP addresses |
Each request unseals the token, calls CloudKit, returns the result and keeps nothing. To be plain about the limit of that statement: while a user is connected, the relay holds a credential that can read that user's zone, and the to-dos pass through its memory on every call. "We do not store or log your to-dos" is the claim we make. "We could not read them" is not. If that is not acceptable, there is the local server (for developers; we do not currently distribute its API token to the public). The stored token is deleted on a disconnect page after the user confirms with Apple, and automatically when CloudKit returns 421.
One design problem is specific to CloudKit sign-in: Apple's callback goes to a fixed URL and cannot carry an OAuth state. We tie the callback to the pending authorization with an HttpOnly, SameSite=Lax cookie. A cookie alone is not enough, though. Someone who could plant a cookie and a session of their own in a victim's browser would connect the victim's AI client to the wrong iCloud. So the callback only accepts an authorization request on which the consent button was pressed within the last three minutes, and consumes it exactly once with a conditional write.
What we gave up
- iOS 26 or later only. AlarmKit does not exist before it, and a notification is not a substitute for an alarm that rings until stopped.
- Apple platforms only. No Android, no web app, no sign-in other than the Apple ID on the device.
- The relay holds a token. The App Store privacy label for MustDo says "Data Linked to You: Identifiers (User ID)" for that reason. It is not "Data Not Collected", and we should not describe it that way.
- No server-side enforcement. Whether you can add new items is decided on the device with StoreKit. The device mirrors the result into the
Accountrecord, and the MCP tools treat it as a hint. Snooze, done and delete are never gated. - Clocks. Last writer wins on timestamps written by devices. For a personal list on a few devices we accepted that.
Links
- The app: MustDo: To-Do Alarm
- Setup and tool reference: MustDo MCP
- Source of the MCP server (MIT): github.com/lightning-llc-jpn/mustdo-mcp
Related
How to make a reminder that rings until it's done (for people who miss notifications)
For people who keep missing notifications: a step-by-step guide to building a reminder on iPhone that keeps ringing until you stop it and snoozes until the task is done, from free options to AlarmKit apps on iOS 26.
Read more →Medicine, bills, trash day: set the to-dos you cannot forget just once
Daily medicine, a monthly bill, weekly trash day. Concrete examples of setting each up once on iPhone and letting an alarm that rings until you stop it do the remembering: choosing repeats, what "Tomorrow" does to a repeating to-do, picking the right time.
Read more →How to get an iPhone reminder that rings on Silent mode (and what AlarmKit is)
Why iPhone reminders stay quiet on Silent mode and in Focus, and how AlarmKit in iOS 26 lets apps ring like a Clock app alarm. Notifications and alarms compared in one table.
Read more →