カバー画像

毎朝8時に通知が鳴って、タップするとその日のニュースが10本並ぶ。それだけのアプリを作りました。

業界ニュースの要約は、しばらく前から Claude Code Routines(Claude の定期実行機能。以下ルーティン)に任せていました。毎朝10本ほど選んで、要約まで付けてくれます。内容には満足していました。問題は受け取り方です。

届くのは Claude アプリの通知だけでした。通知からその日の結果をたどることはできるのですが、挙動が不安定で、タップしても結果が表示されないことがありました。PC から履歴をたどれば読めますが、スマホからさっと見返す手段はありません。以前ご紹介したイベント探索のルーティンのように Slack へ投稿する手もありますが、業務連絡も雑談もアラートも流れてくる場所では、あっという間に埋もれます。

集める側はAIが解決してくれたのに、受け取る側が解決していなかったわけです。

というわけで、この用途だけの小さなアプリを自分用に作ってみました。条件はひとつだけ、サーバーの運用はしたくない。EC2 を借りて OS のパッチを当てて証明書を更新して……という世界に、個人の通知アプリのために戻りたくはありませんでした。

📝 構成から実際にハマった点まで一通り載せたので、そこそこの長さになっています。気になるところは目次から拾い読みしてもらえればと思います!

目次

  1. 作ったもの
  2. 配信内容はプロンプト次第
  3. ぶつかった2つの壁 ― 直接 POST できない、毎朝だけ壊れる
  4. AIエージェントを実運用に組み込むということ
  5. コードを公開しています
  6. まとめ

1. 作ったもの

毎朝8時、Android の通知が鳴ります。タップすると、その日の記事が並びます。

毎朝届く通知
毎朝8時に届く通知
「今日」タブ
タップすると開く「今日」タブ

タブは「今日」「カテゴリ」「履歴」「保存済み」の4つだけです。カテゴリで絞り込み、過去14日分をさかのぼり、気になった記事は保存できます。保存したものは14日を過ぎても消えないので、あとからゆっくり読み返せます。

「カテゴリ」タブ
カテゴリで絞り込む
「履歴」タブ
過去14日分をさかのぼる
「保存済み」タブ
保存したものは14日を過ぎても残る

全体はこんな流れになっています。

Claude のルーティン(毎朝8時)
  → ニュースを収集・要約
  → digest/latest.json として GitHub に push
       ↓ (push をトリガー)
GitHub Actions
  → バックエンドの /ingest に POST
       ↓
Cloudflare Workers + D1
  → 記事を保存し、登録端末に FCM でプッシュ通知
       ↓
Android アプリ
  → 通知をタップすると「今日」タブに一覧表示

技術選定の理由は、ぜんぶ「サーバーを運用したくない」の一点です。

Cloudflare Workers + D1 は、サーバーもコンテナも管理しません。デプロイは wrangler deploy の一行だけ。1日1回、10件を書き込んで、たまに読み出す程度の用途なので、いまのところ無料枠に収まっています。cron トリガーも標準機能なので、古い記事を消すバッチのために別の仕組みを用意する必要もありません。

Firebase Cloud Messaging は、Android にプッシュ通知を送るなら事実上の標準です。

Android アプリは Kotlin + Jetpack Compose。今日 / カテゴリ / 履歴 / 保存済みの4タブだけの素朴な作りです。Android にしたのは、単に自分が持っているのが Android だからでした。iOS なら SwiftUI、複数端末で見たいなら Web でよくて、バックエンドから先はそのまま使い回せます。

ちなみに、開発中に Android Studio を一度も開きませんでした。ビルドもエミュレータの起動も実機へのインストールも、すべてコマンドラインで完結しました。

./gradlew installDebug                      # 実機にインストール
adb logcat -s NewsDigest:V                  # ログを見る
adb exec-out screencap -p > /tmp/s.png      # スクリーンショット

GUI が要るのはデバッガのステップ実行やプロファイラくらいで、日常のビルド→実行→ログ確認のループはターミナルだけで回りました。

2. 配信内容はプロンプト次第

実用上、ここがいちばん大事なところだと思っています。

何をどう集めるかは、すべてプロンプトで決まります。 コードを触る必要はありません。

Claude に渡しているプロンプトは、ざっとこんな内容です。

過去24時間の各業界のニュースから、主要な10本を取得して要約してください。

1. テック、金融、ヘルスケア、小売、エネルギー、製造など主要セクターから、注目すべきニュース10件を選定する。
2. 各ニュースについて、見出し・業界/セクター・要点を1〜2文でまとめる。
3. ビジネスへの影響範囲が広いもの、市場への影響が大きいものを優先する。
4. 各ニュースには、以下のカテゴリのいずれか1つを付与する:
   テック, 金融, ヘルスケア, 小売, エネルギー, 製造

「10本」を「5本」にすれば5本になります。「テック、金融、…」を「製造業とサプライチェーンに限定」に書き換えれば、その領域だけが集まります。「国内ニュースのみ」「英語圏のソースを優先」なども同様です。

プロンプトの書き換えだけで済むのは、カテゴリをコードにも DB にも定数として定義していないからです。スキーマ上、カテゴリはただの文字列にしてあります。

category TEXT NOT NULL,   -- テック/金融/ヘルスケア/... など

カテゴリ一覧を返す API も、固定リストではなく実データを集計して返します。

SELECT category, COUNT(*) FROM articles GROUP BY category

アプリ側もその結果をそのまま描画するだけで、カテゴリ名をハードコードしている箇所はありません。だからプロンプトに新しいカテゴリを足せば、その日から新しいタブの中身が増える。バックエンドもアプリも変更不要です。

逆に言うと、新しい「項目」を足すときはコードが要ります。たとえば各記事に「重要度」を持たせたければ、スキーマ・バックエンド・アプリの3箇所を触ることになります。この線引きは設計のときに意識しておくと、後がずいぶん楽になります。

変えたいもの 必要な作業
カテゴリの種類、記事数、選定基準、収集対象 プロンプトだけ
新しい項目(重要度、関連銘柄など) スキーマ + バックエンド + アプリ
表示や操作(検索、共有、既読管理) アプリだけ

3. ぶつかった2つの壁 ― 直接 POST できない、毎朝だけ壊れる

ここからは、同じような仕組みを組むときにハマるかもしれない話を2つ、共有しておきます。

壁1: ルーティンから直接 POST できない ― 通信制限と、シークレットの置き場

当初の設計はもっと単純でした。Claude がニュースを集めて、そのまま Claude が curl でバックエンドの /ingest を叩く。設計としてはそれだけです。ですが、実行すると失敗しました。

curl: (56) CONNECT tunnel failed, response 403
xxx.workers.dev:443 — connect_rejected
(the egress proxy denied the CONNECT (organization policy))

クラウドで動くセッションは、外向きの通信が既定で制限されています。私の環境で確認した範囲では、届くのは api.anthropic.com や npm・PyPI といったパッケージレジストリなど、あらかじめ許可されたドメインだけでした。xxx.workers.dev はそこに入っていません。

とはいえ、これは設定で開けられます。クラウド環境のネットワークアクセスをカスタムにすると「許可されたドメイン」欄が現れるので、そこに1行足せば通ります(設定ページではなくルーティンの編集画面の中にあって、見つけるのに少し手こずりました)。

ただ、開けたとしても /ingest を叩くには認証情報が要ります。ルーティンにそれを持たせる場所は環境変数欄しかないのですが、その欄にはこう書いてあります。

この環境を使用するすべてのユーザーに表示されるため、シークレットや認証情報は追加しないでください

置くなと明記されている場所に置くのは、さすがに筋が悪い。ここで直接 POST は諦めました。

ルーティンをやめて、Workers から Anthropic API を直接呼ぶ手もあります。これなら制約とは無縁です。ただ試算すると月8〜11ドル。うち Web 検索が $10 / 1,000 回 × 約330回で月$3.3、残りがトークン代という内訳です(執筆時点の料金での概算。API の価格は改定されるので、試す際はご自身で最新の料金表を確認してください)。いまのルーティンは Claude のサブスクリプションの範囲内で動いているので、足りているものをわざわざ従量課金に移し替えることになります。個人用の通知アプリに、追加で出す費用としては割に合いませんでした。

そこで思い出したのが、以前ご紹介したイベント探索のルーティンが GitHub にブランチを push できていたことでした。GitHub との連携はコネクターの機能として用意されているので、自分でトークンを配置しなくても書き込めます。ならば、GitHub を中継点にすればいい。

Claude のルーティン → GitHub に push
                    ↓ (push をトリガー)
                GitHub Actions → /ingest に POST

これならルーティンはシークレットを一切持ちません。JSON を書いて push するだけ。実際の送信は GitHub Actions が担い、認証情報はリポジトリの Secrets に置きます。

📝 push 先は main で、Actions は digest/latest.json の変更だけを拾って発火させています。前記事では claude/ プレフィックスのブランチを使いましたが、これは「無条件に push が通る」ブランチという意味で、そこにしか push できないわけではありません。プロンプトで別のブランチを指定した場合は「保護されていないか」「他人の未マージ PR がないか」「他人のコミットが混ざっていないか」がチェックされ、問題がなければ通ります。

経由する場所は増えましたが、それぞれ独立して確認できるので、障害時の切り分けはむしろ楽になりました。

壁2: タイムゾーンのズレで、毎朝だけ壊れる

パイプラインが通り、実機で通知を受け取れることも確認できました。そして翌朝。

通知は届いた。しかしアプリを開くと記事が1件も表示されない。

通知が届いたということは、FCM もバックエンドへの投入も動いています。届いているはずのデータが画面に出てこないだけです。まずはアプリを疑いました。

DB を覗いて、原因がわかりました。

date: "2026-09-01", 件数: 10

その日は9月2日でした。記事は保存されているのに、日付が1日前になっていました。

原因はこうです。ルーティンは 23:00 UTC に起動します。これは日本時間の朝8時ですが、UTC ではまだ前日です。サンドボックスの時計は UTC なので、Claude は配信日を「9月1日」と判断して送信していました。一方、当日分を返す API は日本時間基準で「9月2日」を探します。当然、一件も一致しません。

プロンプトには「今日の日付を YYYY-MM-DD 形式、日本時間基準で」と明記していました。それでも守られませんでした。実行環境の date コマンドが UTC を返す以上、指示だけでは担保できなかったのです。

厄介なのは、前日の手動テストでは一度も再現しなかったことです。私がテストを実行したのは 14:23 UTC、日本時間の23時23分でした。この時間帯は UTC の日付と JST の日付が一致します。ズレが出るのは日本時間の0時から9時までの9時間だけ。そして毎朝の配信時刻は、ちょうどその時間帯の内側にありました。

📝 時刻を扱うときは、タイムゾーンを意識しましょう!

修正方針として、プロンプトの文言を強めることもできました。「TZ=Asia/Tokyo date +%F を使うこと」と書けば、たしかに直るでしょう。

でも、そうはしませんでした。指示ではなく仕組みで担保するほうに倒しています。

// 配信日は送信側の値を信用せず、必ずサーバー側でJSTとして決める。
const date = todayJst();

バックエンドには元々 todayJst() という関数があり、当日分の取得も古い記事の削除もそれを使っていました。投入処理だけがクライアントから送られてきた値を信用していた。そこが誤りでした。

配信日は、受信側のサーバーで日本時間として決めるようにしました。送信側がどんな日付を送ってきても使われないので、同じ原因で崩れることはありません。

4. AIエージェントを実運用に組み込むということ

2つの壁は、種類がまるで違いました。片方は置き場所の話、もう片方は環境の制約です。

壁1で効いたのは、エージェントにシークレットを持たせないという線引きでした。通信を開けること自体はできたのに、認証情報の置き場が用意されていない。そこで無理に持たせず、送信そのものを外に出した。結果として、ルーティンが漏らしうるものは何もなくなりました。

壁2のほうは、プロンプトに書いたことが実行環境の都合で簡単に破られるという話です。「日本時間基準で」と書いても、date が UTC を返す環境ならそれまで。エージェントは指示を無視したわけではなく、指示を実行しようとした結果、環境の制約に負けただけです。

だとすれば、対処は決まってきます。プロンプトを厚くするのではなく、破られたときに壊れない設計にする。日付はサーバーが決める。シークレットはエージェントに持たせない。失敗したら黙って終わらず、目に見える形で報告させる。プロンプトは仕様書ではなく、お願いです。

もうひとつ実感したのは、実運用の時刻で動かすまで見つからない不具合があるということ。テストは通る、手動実行も通る、実機でも動く。それでも本番の時間帯でだけ壊れる。定期実行を組むときは、実際の実行時刻に近い条件で一度は流してみる価値があります。

5. コードを公開しています

今回作ったものを、サンプルとして公開しました。

github.com/Burning-Chai/news-digest-sample

  • backend/ … Cloudflare Workers + D1。記事の保存、配信 API、FCM 送信
  • android/ … Kotlin + Jetpack Compose の4タブアプリ
  • .github/workflows/ingest.yml … push を検知してバックエンドに転送
  • routine-prompt.md … ルーティンのプロンプトと、GitHub を中継する理由

URL や ID はすべてプレースホルダに置き換えてあるので、README の手順に沿って自分の値を入れれば動きます。

6. まとめ

「毎日ニュースをまとめてほしい」という要求そのものは、AIエージェントがとっくに解決してくれていました。残っていたのは受け取り方の問題で、そこはまだ自分で作る必要があった、という話です。

そして作ってみると、難しかったのはアプリでもバックエンドでもなく、エージェントを実運用の流れに組み込む部分でした。ネットワークの制約と、時刻のズレ。どちらも「動くものを作る」とは別の種類の難しさでした。

できたこと

  • ✅ 配信内容はプロンプトを書き換えるだけで変わる
  • ✅ 見せ方はアプリ側で好きに拡張できる
  • ✅ サーバー管理なしのクラウド実行(ルーティン + Cloudflare Workers)
  • ✅ 壊れやすいところは指示ではなく仕組みで担保する
  • ✅ シークレットはエージェントに持たせない

この分担が思いのほか効いていて、しばらくは実際に毎朝使っています。

AIに何かを任せ始めると、たいてい次に「その結果をどう受け取るか」で詰まります。今回はそれがニュースでしたが、日報でも監視結果でも構造は同じだろうな、と思っています。

アットウェアでは、こういう「AIに任せたあとの、受け取り方」まで含めた取り組みを日々あれこれ試しています。「うちのこの情報も、こういう形で受け取れる?」「定期実行を組んでみたいけれど、やり方が合っているか見てほしい」など、気になることがありましたらお問い合わせページからお気軽にご連絡ください。

この記事が役に立ったら、シェアしていただけると嬉しいです。質問やご指摘もお待ちしています!