全体の形

MustDo(やるまで鳴るアラーム)がやることは 1 つです。TODO と時刻を登録すると、その時刻に AlarmKit のアラームが止めるまで鳴る。止める=「やった」。まだなら、10 分後・1 時間後・明日のどれかにスヌーズします。

構成要素は 3 つあります。どれが私たちの持ち物なのかを、はっきり書いておきます。

  • アプリは、手元の写しと未送信の変更(Outbox)を持ち、TODO を AlarmKit のアラームに変えます。
  • 利用者の CloudKit プライベートデータベースが正本です。TODO を保存するサーバーやデータベースを私たちは運用していません。アカウントの作成もありません。
  • MCP で、AI クライアントがそのレコードを読み書きします。Mac のローカルサーバーは Apple を直接呼びます。claude.ai と iPhone の Claude アプリ向けには中継があり、これは私たちのサーバーです。中継が保存するのは利用者ごとの暗号化した CloudKit サインイントークンで、TODO そのものは保存しません。

最初の計画は、ごく普通のバックエンドでした。API、リレーショナルデータベース、メールでのサインイン、自前のプッシュ。作りかけたところで「これは本当に要るのか」と考え直し、CloudKit に切り替えました。サインイン画面もプッシュの基盤も運用費も要らなくなりました。代わりに手放したものは、最後にまとめます。

AlarmKit: やるまでスヌーズ

AlarmKit には組み込みのスヌーズがありますが、長さを登録時に決めるカウントダウンが 1 つあるだけです。私たちが欲しかったのは、鳴った時点で選べる 3 つの選択肢でした。そこで組み込みのスヌーズは使わず、鳴っているアラームを止めて、新しい ID で登録し直すことをスヌーズにしました。

OS が描く全画面のアラートに置けるボタンは、停止ともう 1 つだけです。割り当てはこうしました。

場所ボタン実装
全画面アラート停止=やったstopIntent
全画面アラート10 分後secondaryButtonBehavior: .custom と secondaryIntent
Live Activity・アプリ内の帯やった・10 分後・1 時間後・明日Button(intent:)

設計全体が 1 つの問いにかかっていました。アラームが鳴っている最中に、LiveActivityIntent の perform() から AlarmManager.schedule を呼べるのか。答えは「呼べる」でした。シミュレータで鳴動中に 2 つ目のボタンを押すと、システムのログに Executing intent for action secondary、Stopping alarm、新しい ID での Scheduling alarm が並び、10 分後にもう一度鳴りました。ロック画面と、そこに出る 4 つボタンの Live Activity まで含めてシミュレータで一通り確かめてから、残りを作りました。

記録が先、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 のアラームの UUID は、アプリ側で決められます。私たちは TODO の ID と、分単位に切り捨てた発火時刻だけから作っています。

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)) }
}

計画を作り直すたびに ID が変わると、同期のたびに取り消しと再登録が起きます。その 2 つの間に発火時刻が挟まると、アラームは消えます。ID が変わらなければ、同期は「いま登録されているもの」との差分を取るだけで済みます。スヌーズは発火の分が変わるので、特別な扱いをしなくても別の ID になります。タイトルと音は ID に入れず、別に持つ指紋で「作り直しが要るか」を判定しています。

繰り返しはアプリが展開する

AlarmKit には曜日で繰り返すスケジュールがありますが、使っていません。繰り返しの TODO で「明日」を押したときの意味は「今日の回だけ飛ばす」で、この組み合わせを 1 つの純粋関数で扱いたかったからです。繰り返しの規則はアプリが自分で展開し、14 日先までを日時指定のアラームとして先に登録します。毎日の TODO がアラームの枠を使い切らないよう、繰り返し 1 件につき 3 回までです。繰り返しの「明日」は、新しいアラームを登録しません。今日の回をスキップにするだけで、明日の回はすでに登録されています。

CloudKit のプライベートデータベースをバックエンドにする

データはすべて、利用者のプライベートデータベースの中の専用ゾーン 1 つに入れています。専用ゾーンにすると、変更トークンで差分だけを取れます。レコードの種類は少なめです。

  • 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 にサインインしていなくても、アラームは登録されます。

ほかの端末や MCP サーバーからの変更は、CKDatabaseSubscription(shouldSendContentAvailable = true)で iPhone に届きます。どこから書いてもサイレントプッシュになり、アプリはバックグラウンドで同じ同期を走らせます。プッシュを送る仕組みを私たちは持っていません。

削除はまず論理削除です。ほかの書き手に「消えた」ことが伝わるよう、deletedAt を書きます。そのうえで 1 日 1 回、取得が成功したあとにアプリが iCloud から実際に消します。完了した単発の TODO は完了から 30 日後、論理削除したものは 14 日後です。MCP サーバーは実際の削除を行いません。

MCP その 1: Apple を直接呼ぶローカルサーバー

ローカルの MCP サーバーは、stdio で話す小さな Node のプログラムです。Apple の REST である CloudKit Web Services を使い、アプリと同じコンテナ・同じゾーンを読み書きします。トークンは 2 つあり、役割が違います。

  • API Token はコンテナを指し示し、サインイン後に Apple が戻す先の URL を固定します。Apple はクライアント側で使う前提で設計しており、これだけではどのプライベートデータベースにも入れません。
  • Web 認証トークンは利用者自身のセッションで、Apple のサインイン画面が発行します。利用者のプライベートデータベースを開くのはこちらです。ローカルでは、ホームディレクトリの下のファイルに権限 0600 で保存します。

サインイン画面の URL は、Web 認証トークンを付けずに users/caller を呼ぶと分かります。CloudKit は HTTP 421 と redirectURL を返すので、それをブラウザで開き、localhost で待ち受けます。Apple はセッションをクエリに付けて戻してきます。セッションが切れたことも、同じ 421 で分かります。

つまずいたところです。

  • API Token は環境ごと。結局、Development 用と Production 用を、ローカルサーバー用と中継用のそれぞれに分けて作りました。保存したセッションも環境ごとなので、切り替えたらサインインし直しです。
  • Production の API Token は、サインインの戻り先に localhost を選べない。Dashboard で選べるのは https か独自スキームです。そこで、私たちのサイトに小さな https の受け口を足しました。届いたクエリをそのまま http://localhost の待ち受けポートへ 302 で送るだけのもので、オープンリダイレクトにならないようホストは固定、クエリは保存もログへの記録もしません。つまり、ローカルサーバーのデータのやり取りは Mac と Apple の間だけで行われますが、サインインの戻りだけはこの受け口を 1 回通ります。
  • users/caller は public database にしか無い。private database に投げると BAD_REQUEST が返ります。

MCP その 2: claude.ai と iPhone アプリのための中継

claude.ai のコネクタと iPhone の Claude アプリは、HTTPS の MCP サーバーにしか接続できません。ここだけはサーバーを置くしかありませんでした。中継は、ローカルサーバーと同じソースから作った同じ 7 つのツールを、ステートレスな Streamable HTTP で出しています。手前には OAuth 2.1 の認可サーバーを置きました(PKCE S256 必須、リダイレクト先を許可リストで絞った動的クライアント登録、再利用を検知するリフレッシュトークンのローテーション)。

中継には自前の保存先があります。DynamoDB のテーブル 1 つです。そこに入れるものと、入れないものは次のとおりです。

保存する保存しない・ログにも書かない
利用者の CloudKit の Web 認証トークン。AWS KMS が利用者ごとに発行するデータキーで AES-256-GCM で封印し、暗号化コンテキストに利用者のハッシュ化した ID を入れるTODO の内容(タイトル・メモ・時刻)
OAuth のアクセストークンとリフレッシュトークン。ペッパー付きの SHA-256 ハッシュだけApple ID のメールアドレスとパスワード(サインインは Apple の画面で行う)
利用者の ID。CloudKit のユーザーレコード名の HMACユーザーレコード名そのもの、IP アドレス

リクエストごとにトークンの封印を解き、CloudKit を呼び、結果を返して、何も残しません。ただし、この言い方の限界も書いておきます。接続している間、中継はその利用者のゾーンを読める認証情報を持っていて、TODO は呼び出しのたびに中継のメモリを通ります。私たちが言えるのは「TODO を保存しない、ログに書かない」までで、「読むことができない」とは言えません。それが受け入れられない場合のために、ローカルサーバーがあります(開発者向け。API Token は現在、一般には配布していません)。預かったトークンは、接続解除のページで Apple による本人確認をすれば削除され、CloudKit が 421 を返したときにも自動で削除されます。

CloudKit のサインインに特有の設計上の問題が 1 つありました。Apple からの戻り先は固定の URL で、OAuth の state を持ち回れません。そこで、戻ってきたリクエストと進行中の認可リクエストは、HttpOnly・SameSite=Lax の cookie で結び付けています。ただ、cookie だけでは足りません。他人のブラウザに自分の cookie とセッションを持ち込める攻撃者がいれば、その人の AI クライアントを別の iCloud につなげてしまえます。そのため、戻り先の受け口は「同意ボタンが押されてから 3 分以内」の認可リクエストだけを受け付け、条件付きの書き込みで 1 回だけ消費するようにしています。

手放したもの

  • 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 を試してみる

TODO と時刻を登録するだけ。iOS 26 以降・30 日間無料。

App Store →

関連記事

通知を見逃す人のための、「やるまで鳴る」リマインダーの作り方

リマインダーの通知に気づけない人向けに、iPhone で「止めるまで鳴り、やるまでスヌーズする」リマインダーを作る手順。無料でできる方法と、iOS 26 の AlarmKit を使った方法を順に。

続きを読む →

薬・支払い・ゴミ出し: 忘れられない用事を 1 回で済ませる設定例

毎日の薬、月に一度の支払い、週に一度のゴミ出し。iPhone で一度だけ設定して、あとは止めるまで鳴るアラームに任せる具体例。繰り返しの決め方、「明日」スヌーズの意味、時刻の選び方。

続きを読む →

iPhone でマナーモードでも鳴るリマインダーを作るには(AlarmKit とは何か)

iPhone のリマインダーがマナーモード(消音スイッチ)や集中モードで鳴らない理由と、iOS 26 の AlarmKit で「時計アプリと同じ本物のアラーム」として鳴らす方法。通知とアラームの違いを表で整理。

続きを読む →