全体の形
MustDo
構成要素は
- アプリは、
手元の 写しと 未送信の 変更 (Outbox)を 持ち、 TODO を AlarmKit の アラームに 変えます。 - 利用者の
CloudKit プライベートデータベース が正本です。 TODO を 保存する サーバーや データベースを 私たちは 運用していません。 アカウントの 作成も ありません。 - MCP で、
AI クライアントが その レコードを 読み 書きします。 Mac の ローカルサーバーは Apple を 直接呼びます。 claude.ai と iPhone の Claude アプリ向けには 中継が あり、 これは 私たちの サーバーです。 中継が 保存するのは 利用者ごとの 暗号化した CloudKit サインイントークンで、 TODO その ものは 保存しません。
最初の
AlarmKit: やるまでスヌーズ
AlarmKit には
OS が
| 場所 | ボタン | 実装 |
|---|---|---|
| 全画面アラート | 停止=やった | stopIntent |
| 全画面アラート | 10 分後 | secondaryButtonBehavior: .custom とsecondaryIntent |
| Live Activity・アプリ内の | やった・10 分後・1 時間後・ | Button(intent:) |
設計全体がLiveActivityIntent のperform() からAlarmManager.schedule をExecuting intent for action secondary、Stopping alarm、Scheduling alarm が
記録が先、OS の操作は後
Intent の
// 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()
先にschedule が
Intent には、
アラームの ID は決定的に作る
AlarmKit の
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)) }
}
計画を
繰り返しはアプリが展開する
AlarmKit には
CloudKit のプライベートデータベースをバックエンドにする
データは
- TODO 1 件に
つき 1 レコード。 レコード名は アプリが 採番する UUID なので、 同じ 作成を 2 回送っても 1 件に しかなりません。 冪等性の ための キーは 要りませんでした。 - 繰り返しの
TODO は、 操作の あった 日ごとに Occurrenceレコードを1 つ。 名前は <id>|<YYYY-MM-DD>で、その 日の 「やった・スキップ・スヌーズ」だけを 持ちます。 Accountレコードが1 つ。 ロケール、 タイムゾーン、 既定の 時刻を 持ち、 MCP 側が 「今日」や 「明日の 既定時刻」を iPhone と 同じ 答えで 計算できるように しています。
同期の
- 送信は
.ifServerRecordUnchangedで保存します。 serverRecordChangedが返ったら、 エラーに 入っている サーバー側の レコードを 取り、 こちらの 変更の ほうが 新しい ときだけ値を 載せて、 もう 一度だけ保存します。 日ごとの 状態は changedAtの後 勝ちです。 - 取得は、
保存しておいた サーバー変更トークンを 渡して recordZoneChangesを呼びます。 初回は 全件です。 - 合成では、
内容は サーバー側が 勝ち、 日ごとの 状態は changedAtの新しい ほうが 勝ち、 Outbox に 残っている 分は 端末の 値を 保ちます。 同じ回への 操作は Outbox の 中で 後の 1 件に まとめるので、 「10 分後」の あとに 押し直した 「1 時間後」が、 順序の 入れ替わりで 負ける ことは ありません。 - 計画と
反映 は手元の 写しから 動きます。 オフラインでも、 iCloud に サインインしていなくても、 アラームは 登録されます。
ほかのCKDatabaseSubscription(shouldSendContentAvailable = true)で
削除はdeletedAt を
MCP その 1: Apple を直接呼ぶローカルサーバー
ローカルの
- API Token は
コンテナを 指し示し、 サインイン後に Apple が 戻す先の URL を 固定します。 Apple は クライアント側で 使う 前提で 設計しており、 これだけではどの プライベートデータベースにも 入れません。 - Web 認証トークンは
利用者自身の セッションで、 Apple の サインイン画面が 発行します。 利用者の プライベートデータベースを 開くのは こちらです。 ローカルでは、 ホームディレクトリの 下の ファイルに 権限 0600 で 保存します。
サインイン画面のusers/caller をredirectURL を
つまずいた
- API Token は
環境ごと。 結局、Development 用と Production 用を、 ローカルサーバー用と 中継用の それぞれに 分けて 作りました。 保存した セッションも 環境ごとなので、 切り 替えたら サインインし直しです。 - Production の
API Token は、 Dashboard でサインインの 戻り先に localhost を 選べない。 選べるのは https か 独自スキームです。 そこで、 私たちの サイトに 小さな https の 受け口を 足しました。 届いた クエリを そのまま http://localhostの待ち受けポートへ 302 で 送るだけの もので、 オープンリダイレクトに ならないようホストは 固定、 クエリは 保存も ログへの 記録もしません。 つまり、 ローカルサーバーの データの やり取りは Mac と Apple の 間だけで 行われますが、 サインインの 戻りだけは この 受け口を 1 回通ります。 users/callerはpublic database に private database にしか 無い。 投げると BAD_REQUESTが返ります。
MCP その 2: claude.ai と iPhone アプリのための中継
claude.ai の
中継には
| 保存する | 保存しない |
|---|---|
| 利用者の | TODO の |
| OAuth の | Apple ID の |
| 利用者の | ユーザーレコード名 |
リクエストごとに
CloudKit のstate を
手放したもの
- iOS 26 以降だけ。それより
前の iOS に AlarmKit は ありません。 通知は、 止めるまで 鳴る アラームの 代わりに なりません。 - Apple の
プラットフォームだけ。 Android もWeb アプリもなく、 サインインは 端末の Apple ID だけです。 - 中継は
トークンを MustDo の預かる。 App Store の プライバシー表示が 「Data Linked to You: Identifiers (User ID)」 (ユーザに 関連付けられた データ: ID)に なっているのは、 この ためです。 「データの 収集なし」ではないので、 そうは 書きません。 - サーバー側での
強制が 新しい無い。 TODO を 追加できるか どうかは、 端末の StoreKit で 決まります。 端末が その 結果を Accountレコードに写し、 MCP の ツールは それを 目安と して 扱います。 スヌーズ・完了・削除には 制限を かけていません。 - 時計。後
勝ちの 判定に 使うのは、 端末が 書いた 時刻です。 個人が 数台で 使うリストなら、 これで 足りると 判断しました。
リンク
- アプリ: MustDo
(やるまで 鳴る アラーム) - 設定の
手順と ツールの 一覧: MCP の 使い方 - MCP サーバーの
ソース (MIT) : github.com/lightning-llc-jpn/mustdo-mcp
関連記事
通知を見逃す人のための、「やるまで鳴る」リマインダーの作り方
リマインダーの
薬・支払い・ゴミ出し: 忘れられない用事を 1 回で済ませる設定例
毎日の
iPhone でマナーモードでも鳴るリマインダーを作るには(AlarmKit とは何か)
iPhone の